a3s-sandbox 0.1.3

Cross-platform native command sandbox for A3S
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Typed, versioned sandbox policy document (Gate 1).

use anyhow::{bail, Result};
use std::collections::BTreeSet;

/// Policy schema version carried in every digest.
pub const POLICY_VERSION: u32 = 1;

/// Public, platform-neutral policy document.
///
/// Backends must enforce this document (or a compiled view of it). They must
/// not invent broader permissions when a host feature is missing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxPolicy {
    pub version: u32,
    pub filesystem: FilesystemRules,
    pub network: NetworkRules,
    pub sockets: SocketRules,
    pub resources: ResourceLimits,
    pub features: FeatureFlags,
}

/// Filesystem allow/deny sets. Deny wins over allow. Write exceptions apply
/// after deny-write (carve-outs under an otherwise denied ancestor).
///
/// Gate 3 mounts are typed roots with explicit modes. Empty `mounts` keeps the
/// Gate 0/1 workspace+scratch baseline. `session_write` defaults to
/// [`SessionWriteMode::Persistent`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FilesystemRules {
    pub allow_read: Vec<PathRule>,
    pub deny_read: Vec<PathRule>,
    pub allow_write: Vec<PathRule>,
    pub deny_write: Vec<PathRule>,
    pub write_exceptions: Vec<PathRule>,
    pub mounts: Vec<FilesystemMount>,
    pub session_write: SessionWriteMode,
}

/// How a typed mount root may be used inside the sandbox.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MountMode {
    /// Readable knowledge / tool tree; writes must fail closed.
    ReadOnly,
    /// Writable root. Outside workspace/scratch this still fails closed.
    ReadWrite,
    /// Private scratch root (must resolve under the session scratch tree).
    Scratch,
}

/// A Gate 3 filesystem mount root.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FilesystemMount {
    pub root: PathRule,
    pub mode: MountMode,
}

/// Session write durability. Ephemeral requires an OS overlay/tmpfs (or
/// equivalent); backends that cannot provide it must fail closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionWriteMode {
    #[default]
    Persistent,
    Ephemeral,
}

/// A single path rule. Paths are stored in normalized policy form (`/`
/// separators, no `.` / `..` components after normalization).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PathRule {
    /// Exact path match, or prefix match when the candidate is under this path.
    Exact(String),
    /// Glob match (`*` and `?` only; `**` is rejected at validation time).
    Glob(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkRules {
    pub default: NetworkDefault,
    /// Reserved for Gate 4+. Gate 1 rejects non-empty allow lists unless the
    /// mediated-network feature flag is set—and still rejects them until that
    /// gate ships (fail closed).
    pub allow: Vec<NetworkAllowRule>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkDefault {
    DenyAll,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NetworkAllowRule {
    pub host: String,
    pub port: Option<u16>,
    /// Optional path prefix for HTTP(S) mediation (`/` normalized). Empty
    /// means any path on the origin.
    pub path_prefix: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketRules {
    /// Empty means deny all host Unix-domain sockets (Gate 0 / Gate 1 default).
    pub allow_unix: Vec<PathRule>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceLimits {
    pub timeout_ms: u64,
    pub max_output_bytes: usize,
    pub max_call_depth: Option<u32>,
    pub max_processes: Option<u32>,
    pub max_memory_bytes: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FeatureFlags {
    /// Gate 4. Must stay false until mediated HTTP ships.
    pub mediated_network: bool,
    /// Gate 5. Opt-in SOCKS5 host mediation (requires OS loopback fence).
    pub mediated_socks: bool,
}

impl Default for NetworkRules {
    fn default() -> Self {
        Self {
            default: NetworkDefault::DenyAll,
            allow: Vec::new(),
        }
    }
}

impl Default for ResourceLimits {
    fn default() -> Self {
        Self {
            timeout_ms: 120_000,
            max_output_bytes: crate::MAX_OUTPUT_SIZE,
            max_call_depth: None,
            max_processes: None,
            max_memory_bytes: None,
        }
    }
}

impl Default for SandboxPolicy {
    fn default() -> Self {
        Self {
            version: POLICY_VERSION,
            filesystem: FilesystemRules::default(),
            network: NetworkRules::default(),
            sockets: SocketRules::default(),
            resources: ResourceLimits::default(),
            features: FeatureFlags::default(),
        }
    }
}

impl SandboxPolicy {
    /// Strict A3S Bash baseline document: network deny-all, no mediation flags.
    pub fn a3s_bash_baseline() -> Self {
        Self::default()
    }

    /// Reject malformed or prematurely enabled rules. Never silently repair.
    pub fn validate(&self) -> Result<()> {
        if self.version != POLICY_VERSION {
            bail!(
                "unsupported sandbox policy version {}; only {POLICY_VERSION} is accepted",
                self.version
            );
        }

        for rule in self
            .filesystem
            .allow_read
            .iter()
            .chain(self.filesystem.deny_read.iter())
            .chain(self.filesystem.allow_write.iter())
            .chain(self.filesystem.deny_write.iter())
            .chain(self.filesystem.write_exceptions.iter())
            .chain(self.filesystem.mounts.iter().map(|mount| &mount.root))
            .chain(self.sockets.allow_unix.iter())
        {
            validate_path_rule(rule)?;
        }

        for mount in &self.filesystem.mounts {
            if matches!(mount.mode, MountMode::ReadWrite) {
                // Document-level RW mounts outside workspace/scratch are rejected
                // at compile time; still forbid Glob RW mounts here (Exact only).
                if matches!(mount.root, PathRule::Glob(_)) {
                    bail!(
                        "ReadWrite mounts must use Exact paths; globs would broaden \
                         write surface unpredictably"
                    );
                }
            }
        }

        if self.resources.timeout_ms == 0 {
            bail!("resource limit timeout_ms must be greater than zero");
        }
        if self.resources.max_output_bytes == 0 {
            bail!("resource limit max_output_bytes must be greater than zero");
        }

        let mediation_enabled = self.features.mediated_network || self.features.mediated_socks;
        if !self.network.allow.is_empty() && !mediation_enabled {
            bail!(
                "network allow rules require features.mediated_network or \
                 features.mediated_socks; refuse premature allow lists instead of \
                 silently ignoring them"
            );
        }
        if self.features.mediated_network && self.network.allow.is_empty() {
            bail!(
                "features.mediated_network is set but network.allow is empty; refuse a \
                 mediation flag with no allowlist"
            );
        }
        if self.features.mediated_socks && self.network.allow.is_empty() {
            bail!(
                "features.mediated_socks is set but network.allow is empty; refuse a \
                 mediation flag with no allowlist"
            );
        }
        if self.network.default != NetworkDefault::DenyAll {
            bail!("network default must be DenyAll (mediation is allowlist-only)");
        }

        for rule in &self.network.allow {
            if rule.host.is_empty() || rule.host.contains(['/', '\\', ' ']) {
                bail!("invalid network allow host: {:?}", rule.host);
            }
            if let Some(prefix) = &rule.path_prefix {
                if !prefix.starts_with('/') || prefix.contains("..") {
                    bail!(
                        "network allow path_prefix must be an absolute path without '..': {:?}",
                        prefix
                    );
                }
            }
        }

        Ok(())
    }

    /// Validate against what the current backend can enforce.
    pub fn validate_for_backend(
        &self,
        capabilities: crate::policy::BackendCapabilities,
    ) -> Result<()> {
        self.validate()?;
        if self.features.mediated_network && !capabilities.mediated_http {
            bail!("policy requests mediated_network but backend cannot enforce it; fail closed");
        }
        if self.features.mediated_socks && !capabilities.mediated_socks {
            bail!("policy requests mediated_socks but backend cannot enforce it; fail closed");
        }
        if !self.sockets.allow_unix.is_empty() && !capabilities.unix_socket_allowlist {
            bail!("unix socket allowlist requested but backend cannot enforce it; fail closed");
        }
        if self.resources.max_memory_bytes.is_some() && !capabilities.resource_memory_limit {
            bail!("memory limit requested but backend cannot enforce it; fail closed");
        }
        if self.resources.max_processes.is_some() && !capabilities.resource_process_limit {
            bail!("process limit requested but backend cannot enforce it; fail closed");
        }
        if self.resources.max_call_depth.is_some() {
            bail!("max_call_depth is not enforceable by the OS process boundary; fail closed");
        }
        if !self.filesystem.mounts.is_empty() && !capabilities.filesystem_readonly_mounts {
            bail!(
                "filesystem mounts requested but backend cannot enforce typed mounts; fail closed"
            );
        }
        if self.filesystem.session_write == SessionWriteMode::Ephemeral
            && !capabilities.filesystem_ephemeral_writes
        {
            bail!(
                "ephemeral session writes requested but backend cannot provide overlay/tmpfs \
                 (or equivalent); fail closed instead of emulating an in-process FS"
            );
        }
        if !capabilities.network_deny_all {
            bail!("backend cannot enforce network deny-all; fail closed");
        }
        Ok(())
    }

    /// Canonicalize rule ordering so digests are stable.
    pub fn canonicalized(&self) -> Self {
        let mut policy = self.clone();
        sort_path_rules(&mut policy.filesystem.allow_read);
        sort_path_rules(&mut policy.filesystem.deny_read);
        sort_path_rules(&mut policy.filesystem.allow_write);
        sort_path_rules(&mut policy.filesystem.deny_write);
        sort_path_rules(&mut policy.filesystem.write_exceptions);
        policy.filesystem.mounts.sort();
        policy.filesystem.mounts.dedup();
        sort_path_rules(&mut policy.sockets.allow_unix);
        policy.network.allow.sort();
        policy.network.allow.dedup();
        policy
    }
}

fn sort_path_rules(rules: &mut Vec<PathRule>) {
    let set: BTreeSet<_> = rules.drain(..).collect();
    rules.extend(set);
}

fn validate_path_rule(rule: &PathRule) -> Result<()> {
    let value = match rule {
        PathRule::Exact(value) | PathRule::Glob(value) => value,
    };
    if value.is_empty() {
        bail!("path rules must not be empty");
    }
    if value.contains('\0') {
        bail!("path rules must not contain NUL");
    }
    if value.split('/').any(|part| part == "." || part == "..") {
        bail!("path rules must be normalized before validation: {value}");
    }
    if matches!(rule, PathRule::Glob(_)) && value.contains("**") {
        bail!("recursive glob '**' is not supported; refuse ambiguous rules");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn baseline_policy_validates() {
        SandboxPolicy::a3s_bash_baseline().validate().unwrap();
    }

    #[test]
    fn rejects_wrong_version() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy.version = 99;
        let error = policy.validate().unwrap_err().to_string();
        assert!(
            error.contains("unsupported sandbox policy version"),
            "{error}"
        );
    }

    #[test]
    fn rejects_network_allow_without_mediation_flag() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy.network.allow.push(NetworkAllowRule {
            host: "example.com".into(),
            port: Some(443),
            path_prefix: None,
        });
        let error = policy.validate().unwrap_err().to_string();
        assert!(error.contains("mediated_network"), "{error}");
    }

    #[test]
    fn rejects_mediated_network_flag_without_allowlist() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy.features.mediated_network = true;
        let error = policy.validate().unwrap_err().to_string();
        assert!(
            error.contains("empty") || error.contains("allowlist"),
            "{error}"
        );
    }

    #[test]
    fn mediated_network_document_validates_shape_but_backend_fails_closed() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy.features.mediated_network = true;
        policy.network.allow.push(NetworkAllowRule {
            host: "example.com".into(),
            port: Some(443),
            path_prefix: Some("/v1".into()),
        });
        policy.validate().unwrap();
        let result =
            policy.validate_for_backend(crate::policy::BackendCapabilities::native_gate2());
        if cfg!(any(target_os = "macos", target_os = "linux", windows)) {
            result.unwrap();
        } else {
            let error = result.unwrap_err().to_string();
            assert!(
                error.contains("mediated_network") || error.contains("fail closed"),
                "{error}"
            );
        }
    }

    #[test]
    fn rejects_dotdot_in_path_rules() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy
            .filesystem
            .allow_write
            .push(PathRule::Exact("foo/../bar".into()));
        let error = policy.validate().unwrap_err().to_string();
        assert!(error.contains("normalized"), "{error}");
    }

    #[test]
    fn rejects_recursive_glob() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy
            .filesystem
            .deny_read
            .push(PathRule::Glob("**/secrets".into()));
        let error = policy.validate().unwrap_err().to_string();
        assert!(error.contains("**"), "{error}");
    }

    #[test]
    fn rejects_zero_timeout() {
        let mut policy = SandboxPolicy::a3s_bash_baseline();
        policy.resources.timeout_ms = 0;
        assert!(policy.validate().is_err());
    }
}