diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 75b1e0b3d..cf2552a15 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -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 @@ -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. @@ -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`, ` allow_idx, "deny rules must come after allow rules so they win on last-match" @@ -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() { @@ -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