Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/backends/seatbelt/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ even syntactically valid.
|---|---|---|
| `readonlyPaths` | `(allow file-read* (subpath …))` **plus** `(deny file-write* network-bind network-outbound (subpath …))` | Read the subtree β€” and explicitly *not* write it or use sockets in it |
| `readwritePaths` | `(allow file-read* file-write* network-bind network-outbound (subpath …))` | Read, write, and use AF_UNIX sockets |
| `deniedPaths` | `(deny file-read* file-write* network-bind network-outbound (subpath …))`, emitted **last** | Overrides every allow above it |
| `deniedPaths` | `(deny file-read* file-read-metadata file-write* network-bind network-outbound (subpath …))`, emitted **last** | Overrides every allow above it |

The paired deny on `readonlyPaths` is emitted for **every** read-only entry, not
just nested ones. It matters most when a read-only path sits inside a broader
Expand All @@ -102,6 +102,12 @@ Seatbelt is **last-match-wins** among rules that carry a filter, so denies
emitted after allows win. (An *unfiltered* rule doesn't participate β€” a blanket
`(allow network-outbound)` can't override a path-scoped deny.)

A rule naming an operation outright outranks one that only reaches it through a
wildcard, which is why `deniedPaths` spells out `file-read-metadata` alongside
`file-read*`: the baseline grants metadata reads unfiltered so path resolution
works, and without the explicit mention a denied path would still answer
`stat()` with its real size and timestamps.

Rules are emitted shallow-to-deep, so the **deepest** matching rule wins at any
given path. `deniedPaths` sits outside that ordering and always outranks.

Expand Down Expand Up @@ -157,6 +163,10 @@ and standard tools work:
| Read **+ write** | `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom` |
| Read-data only | `/` itself β€” the loader can't resolve path lookups without it |

Every sandbox also gets an unfiltered `(allow file-read-metadata)`, because the
kernel reads metadata on each ancestor directory while resolving a path.
`deniedPaths` names that operation explicitly so it still outranks the grant.

The `/dev/*` entries are writable because shell redirections (`>/dev/null`,
`</dev/urandom`) need both directions. Writes to `/dev/null` and `/dev/zero` are
discarded; writes to the entropy devices are harmless.
Expand Down
36 changes: 33 additions & 3 deletions src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ pub fn build_profile_with_proxy(
}

/// Baseline allow rules required for any sandboxed process to start.
///
/// The unfiltered `(allow file-read-metadata)` is what path resolution needs β€”
/// the kernel reads metadata on every ancestor directory. `deniedPaths` names
/// `file-read-metadata` explicitly so it outranks this grant.
const BASELINE_ALLOW: &str = "\
;; --- baseline (required for any process to start) ---
(allow process-fork)
Expand Down Expand Up @@ -364,10 +368,16 @@ fn write_filesystem_deny(out: &mut String, paths: &ResolvedPaths) {
// `connect()` to a UNIX socket inside a denied subtree and talk to
// whatever listens there. A Docker / ssh-agent / gpg-agent socket is a
// control plane, so that would be an escape.
//
// `file-read-metadata` is named explicitly even though `file-read*`
// nominally spans it: the baseline emits an unfiltered `(allow
// file-read-metadata)`, and a rule naming the operation outranks one
// that only reaches it through the wildcard. Without it a denied path
// still answers `stat()` with its real size and timestamps.
out.push_str(";; --- policy.deniedPaths (override broader allow rules) ---\n");
write_path_rule(
out,
"deny file-read* file-write* network-bind network-outbound",
"deny file-read* file-read-metadata file-write* network-bind network-outbound",
Comment thread
theelliotm marked this conversation as resolved.
&paths.denied,
);
}
Expand Down Expand Up @@ -874,7 +884,9 @@ mod tests {
r.policy.denied_paths = vec!["/tmp/secret".into()];
let p = build_profile(&r).unwrap();
let allow_idx = p.find("(allow file-read* file-write*").unwrap();
let deny_idx = p.find("(deny file-read* file-write*").unwrap();
let deny_idx = p
.find("(deny file-read* file-read-metadata file-write*")
.unwrap();
assert!(
deny_idx > allow_idx,
"deny rules must come after allow rules so they win on last-match"
Expand Down Expand Up @@ -1385,7 +1397,8 @@ mod tests {
.unwrap_or("")
}
const RW_RULE: &str = "(allow file-read* file-write* network-bind network-outbound\n";
const DENY_RULE: &str = "(deny file-read* file-write* network-bind network-outbound\n";
const DENY_RULE: &str =
"(deny file-read* file-read-metadata file-write* network-bind network-outbound\n";

#[test]
fn readwrite_paths_emit_unix_socket_ops() {
Expand Down Expand Up @@ -1454,6 +1467,23 @@ mod tests {
assert!(p[deny_idx..].contains("(subpath \"/private/tmp/secret\")"));
}

#[test]
fn denied_paths_name_metadata_reads_explicitly() {
// The baseline emits an unfiltered `(allow file-read-metadata)` for
// path resolution. A rule naming that operation outranks one that only
// reaches it through `file-read*`, so the deny has to name it too or a
// denied path still answers `stat()`.
let mut r = req();
r.policy.readwrite_paths = vec!["/tmp".into()];
r.policy.denied_paths = vec!["/tmp/secret".into()];
let p = build_profile(&r).unwrap();
assert!(p.contains("(allow file-read-metadata)"));
let deny_idx = p
.find(DENY_RULE)
.expect("deny must name file-read-metadata");
assert!(p[deny_idx..].contains("(subpath \"/private/tmp/secret\")"));
}

#[test]
fn lexical_spellings_normalize_to_the_same_rule() {
// Each of these accesses `/private/tmp/secret` at the kernel level, so
Expand Down
Loading