fix(seatbelt): allow AF_UNIX sockets and resolve symlinked root paths - #749
Conversation
Two bugs made the macOS Seatbelt backend reject UNIX-domain sockets and silently drop most filesystem policy rules. 1. AF_UNIX sockets were never permitted. Seatbelt matches them by *path* (`network-bind` for bind(), `network-outbound` for connect()), while `allowLocalNetwork` only emits `network-inbound (local ip)`, which covers IP sockets. Any Node toolchain using an IPC pipe (tsx, vite, esbuild, jest workers) failed with `listen EPERM`. Both operations are now granted on `readwritePaths`: a socket is a filesystem object reachable only by processes that can traverse to its path, so using one where writes are already permitted grants no new capability. The rules are path-scoped, so IP bind and IP egress are unaffected and stay governed by the network policy. 2. Policy paths were emitted verbatim. `/etc`, `/tmp`, `/var` and `/home` are symlinks on macOS, and the kernel canonicalises a path before matching it against a profile filter, so those rules never matched and were silently dead. This also killed the automatic $TMPDIR grant, which resolves to `/var/folders/...`. Policy paths are now rewritten to their real targets before emission. Fixing (2) turns dead deny rules live as well as dead allow rules, and `deniedPaths` now also denies `network-outbound`: `defaultPolicy: "allow"` and the remote-proxy fallback emit an unfiltered `(allow network-outbound)`, which otherwise let the sandbox connect() to a pre-existing socket inside a denied subtree — a Docker, ssh-agent or gpg-agent socket is a control plane, so that was an escape. Verified end to end: the bypass reproduces before the change and returns EPERM after. Verified with sandbox-exec and a real mxc-exec-mac build that a path-scoped network-bind/network-outbound rule never matches an IP socket, and that deny ordering holds against `..`, `//` and trailing-slash forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
|
Let me know if y'all think that the socket listen should be behind a new option. |
There was a problem hiding this comment.
🟡 Changes recommended
Lexically equivalent root paths can still leave denied-path rules ineffective.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Fixes Seatbelt filesystem policy handling and AF_UNIX socket access on macOS.
Changes:
- Allows AF_UNIX sockets within read-write paths.
- Resolves known macOS root symlinks.
- Extends denied paths to block socket access.
File summaries
| File | Description |
|---|---|
src/backends/seatbelt/common/src/profile_builder.rs |
Updates profile generation and tests. |
docs/macos-support/seatbelt-backend.md |
Documents path and socket behavior. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Addresses review feedback on microsoft#749. `//tmp/secret`, `/./tmp/secret` and `/tmp/secret/` all reach the same inode, but Seatbelt canonicalizes only the *accessed* path, not the filter, so a rule written with a redundant spelling never matches. For `deniedPaths` that fails open. Confirmed with sandbox-exec: a deny on `(subpath "//private/tmp/x/secret/")` lets the write through, while the normalized spelling denies it. Redundant separators, `.` segments and a trailing `/` are now folded away before the root-symlink rewrite. A `..` segment is rejected as a config error instead: macOS resolves `..` physically, after following symlinks, so `/tmp/..` is `/private` rather than `/` — resolving it lexically could silently widen an allow rule, which is worse than the dead rule it replaces. Also corrects the deny rationale in the code comment and the docs: the `deniedPaths` deny of `network-outbound` overrides a broader `readwritePaths` subtree as well as the unfiltered outbound allow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Resolved path aliases can bypass read-only precedence and become writable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Addresses review feedback on microsoft#749. The shared parser applies most-restrictive-wins (deny > readonly > readwrite) to the raw path strings. That was sufficient while unresolved paths produced dead rules, but now that they resolve, two spellings of the same path survive the parser and collide only at emission time: readonlyPaths: ["/private/tmp/x"] readwritePaths: ["/tmp/x"] Both become `/private/tmp/x`, and since Seatbelt is last-match-wins and the read-write rule is emitted second, the read-only intent was silently lost. Confirmed with a real mxc-exec-mac build: the path was writable before this change and is denied after. Policy paths are now resolved once into a `ResolvedPaths` set that re-applies the precedence on the resolved values, and both rule writers consume it. `write_path_rule` no longer resolves or returns a Result, since its input is already resolved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Nested read-only paths can retain write/socket authority, and valid paths containing .. are rejected only by Seatbelt.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (3)
src/backends/seatbelt/common/src/profile_builder.rs:181
- Resolved precedence still handles only identical paths. For example,
readwritePaths: ["/tmp"]andreadonlyPaths: ["/private/tmp/secret"]now produce a live broad read-write rule after the read-only rule; because the read-only rule does not denyfile-write*, writes and the new socket operations remain allowed insecret. This conflicts with the repository's deepest-path-wins policy (wxc_common/src/filesystem_resolve.rs:12-16) and is a regression made reachable by resolving/tmp. Emit resolved intents shallow-to-deep and explicitly remove write/socket authority for read-only subtrees, with a regression test for this parent/child alias case.
let mut readonly: Vec<String> = readonly;
readonly.retain(|p| !denied.contains(p));
readwrite.retain(|p| !denied.contains(p) && !readonly.contains(p));
src/backends/seatbelt/common/src/profile_builder.rs:558
- This adds a Seatbelt-only rejection for filesystem paths that the shared parser accepts and preserves (
config_parser.rs:280-298). Concrete host paths can legitimately contain..—especially across symlinked roots—so existing cross-backend policies now fail only on macOS. Resolve components with macOS filesystem semantics instead (for non-existent targets, canonicalize the longest existing prefix and append the remaining components safely) rather than requiring callers to know the host's symlink layout.
if path.split('/').any(|seg| seg == "..") {
return Err(format!(
"Filesystem path '{path}' contains a '..' segment. macOS resolves '..' after \
following symlinks, so the generated sandbox rule could not be matched \
reliably; specify the fully resolved path instead."
));
docs/macos-support/seatbelt-backend.md:228
- The claim that this grants no new capability is incorrect: filesystem write permission alone did not authorize
connect(), while the newnetwork-outboundrule permits communication with any pre-existing listener under the read-write subtree. This matters for control sockets and should be documented as a security tradeoff so callers know to avoid broad read-write roots or exclude sensitive sockets.
This is deliberate. The socket is a filesystem object reachable only by
processes that can traverse to its path, so using one where writes are already
permitted grants no new capability — and Node toolchains (tsx, vite, esbuild,
jest workers) need it for their IPC pipes. Gating it behind `allowLocalNetwork`
would force real network ingress on just to run a build. Because the rules are
path-scoped they never widen IP binding or IP egress, which stay governed by
`defaultPolicy` and `allowLocalNetwork`.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Addresses review feedback on microsoft#749. Exact-path precedence was not enough. A `readonlyPaths` entry nested inside a broader `readwritePaths` subtree kept the parent's write and socket authority, because an `allow` rule never takes authority back and the read-write rule was emitted second: readwritePaths: ["/tmp"] -> /private/tmp readonlyPaths: ["/private/tmp/x"] -> writable This existed for already-canonical paths, but resolving `/tmp` made it reachable for many more policies, so it is fixed here. `readonlyPaths` and `readwritePaths` are now ordered by `wxc_common::filesystem_resolve::resolve_path_plan` — the same shallow-to-deep plan the Bubblewrap and LXC backends use — and emitted one rule per path, so last-match-wins gives deepest-intent-wins. Each read-only path also emits an explicit `(deny file-write* network-bind network-outbound …)` to remove authority a shallower read-write rule granted. `deniedPaths` stays outside the plan, emitted after the network rules, so it still overrides the unfiltered `(allow network-outbound)`. Verified both directions with a real mxc-exec-mac build: a read-only path nested in a read-write root is now denied, and a read-write path nested in a read-only root still writes. Docs: record the depth ordering, note that AF_UNIX `connect()` is a capability `file-write*` alone did not grant (so prefer narrow read-write roots and put control sockets in `deniedPaths`), and flag that rejecting `..` is macOS-only divergence from the shared parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
A later unfiltered network allow can override the new read-only socket deny.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Review raised that `write_network_rules` runs after the filesystem
section, so an unfiltered `(allow network-outbound)` from
`defaultPolicy: "allow"` would re-enable AF_UNIX `connect()` under
`readonlyPaths`.
Checked against `sandbox-exec` with a pre-existing listener; it does not.
Seatbelt's last-match-wins applies between rules that carry a filter; an
unfiltered rule does not override a path-scoped one:
fs deny, then unfiltered net allow -> EPERM
unfiltered net allow, then fs deny -> EPERM
unfiltered net allow, no fs deny -> connected
Filtered-vs-filtered does honour order, which is what the shallow-to-deep
emission relies on, so the ordering stays as is. Adding the regression
test that was asked for, plus a doc correction: the guide claimed plain
last-match-wins, which is what made the ordering look unsafe.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Several comments and documentation statements contradict the implemented and tested Seatbelt rule-precedence behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (4)
docs/macos-support/seatbelt-backend.md:198
- This contradicts lines 184–186: the path-scoped deny beats an unfiltered outbound allow regardless of order. Last emission matters for overriding filtered read-write grants; documenting the actual distinction avoids making network-rule ordering look security-sensitive when it is not.
`deniedPaths` is not part of that plan — it is emitted after the network rules
so it also overrides the unfiltered `(allow network-outbound)`, which makes it
win outright regardless of depth.
docs/macos-support/seatbelt-backend.md:285
- The “required in addition to
network-bind” claim conflicts with the generated default-block profile, which emits onlynetwork-inbound, and with this PR's validation that default-block plusallowLocalNetworkcan listen successfully. Describe the observed behavior without requiring a rule that is absent in that configuration.
| `allowLocalNetwork: true` | `(allow network-inbound (local ip))` — required in addition to `network-bind` before the kernel will accept `listen()` on an IP socket. Independent of `defaultPolicy`, and unrelated to AF_UNIX sockets (see above). |
src/backends/seatbelt/common/src/profile_builder.rs:208
- This ordering rationale contradicts the filtered/unfiltered behavior pinned by
readonly_socket_strip_survives_a_default_allow_outbound: a path-filtered deny overrides an unfiltered outbound allow regardless of order. EmittingdeniedPathslast is required to override filtered path grants, not the unfiltered rule; please correct the comment so future changes do not preserve a nonexistent ordering constraint.
// parent's write grant. `deniedPaths` is deliberately not part of this
// plan: it is emitted after the network rules so it also overrides the
// unfiltered `(allow network-outbound)`, which makes it win outright.
docs/macos-support/seatbelt-backend.md:194
- This overstates Seatbelt semantics: a later filtered allow can override an earlier filtered deny, as the shallow-to-deep design itself relies on. The reason for the explicit deny is specifically that
allow file-read*does not revoke write/socket operations granted by a broader rule.
This issue also appears in the following locations of the same file:
- line 196
- line 285
read-write subtree stays read-only. Because an `allow` can never take authority
back from an earlier rule, each read-only path also emits an explicit
`(deny file-write* network-bind network-outbound …)`.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| // denies — so the removal has to be explicit. | ||
| write_path_rule( | ||
| out, | ||
| "deny file-write* network-bind network-outbound", |
There was a problem hiding this comment.
Follow up:
This read-only network-outbound deny is defeated under defaultPolicy: "allow". It's emitted here in the allow phase, so the later unfiltered (allow network-outbound) wins under last-match-wins and re-enables AF_UNIX connect() on the read-only path - the explicit strip becomes a dead rule.
Fix: move the write_network_rules block before write_filesystem_allow?
There was a problem hiding this comment.
Checked this end-to-end and the strip holds — it isn't defeated under defaultPolicy: "allow".
Real mxc-exec-mac, real config (defaultPolicy: "allow", so the unfiltered (allow network-outbound) is emitted after the filesystem section), against a listener created outside the sandbox at /private/tmp/sbt7/ro/s.sock. The two runs differ in one character of config:
readonlyPaths: ["/private/tmp/sbt7/ro"] -> CONNECT_DENIED [Errno 1] Operation not permitted
readwritePaths: ["/private/tmp/sbt7/ro"] -> CONNECT_OK
The control matters: it shows connect() genuinely is reachable there, so the first row is the strip winning rather than a capability that was never granted.
The reason is that Seatbelt's last-match-wins holds between rules that carry a filter. An unfiltered rule doesn't override a path-filtered one, in either direction:
fs deny, then unfiltered net allow -> EPERM (current emission order)
unfiltered net allow, then fs deny -> EPERM (the proposed order)
unfiltered net allow, no fs deny -> CONNECT_OK (control)
Filtered-vs-filtered does honour order (allow then deny on a path denies; reversed allows; shallow deny + deeper allow allows), which is what the shallow-to-deep emission relies on — so moving write_network_rules earlier would be a no-op here.
This is squarely my fault for how it was written up: the guide claimed plain last-match-wins without the filtered/unfiltered distinction, which makes the ordering look load-bearing when it isn't. Both Copilot and you read it the same way, which is a good sign the text was the problem.
Two things already landed / are up:
readonly_socket_strip_survives_a_default_allow_outbound(in 6f70f7b, merged here) pins exactly this case —defaultPolicy: "allow"+readonlyPaths, asserting the strip is emitted and that the unfiltered allow still follows it, so the test stays meaningful if someone reorders.- docs(seatbelt): correct rule-precedence and listen() rationale #754 corrects the docs and comments, including a related one I got backwards:
network-inboundgovernslisten()on its own;network-bindalone is what's insufficient.
Happy to reorder anyway if you'd prefer the profile to read defensively, but it would be for legibility rather than a behavior fix.
…gged Both review passes on microsoft#749 landed on the same line - the read-only network-outbound strip - and asked whether the later unfiltered (allow network-outbound) defeats it. It does not, but nothing at that site said so; the comment there only claimed "an allow never denies", which is the phrasing that fed the wrong model in the first place. Spell it out where the question gets asked: the deny survives because last-match-wins applies between rules that carry a filter, and an unfiltered rule does not override a path-filtered one. Points at the test that pins it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Review feedback: two tests still enforced the ordering constraint this PR documents as nonexistent - a path-filtered deny is not overridden by an unfiltered allow in either direction, so asserting the deny is emitted after (allow network-outbound) encodes a constraint that does not exist. - denied_paths_deny_outbound_after_unfiltered_allow -> denied_paths_deny_outbound_under_default_allow: keeps the real guarantee (a denied subtree still denies network-outbound under defaultPolicy: "allow") and drops the order assertion. - readonly_socket_strip_survives_a_default_allow_outbound: drops the same guard, which was added in microsoft#749 for the same wrong reason. The order assertions that remain are against *filtered* allows, where last-match-wins genuinely applies and the deny must come second: denied_paths_appear_after_allows_to_override and denied_paths_deny_unix_socket_ops_after_allows. Those stay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* docs(seatbelt): correct rule-precedence and listen() rationale Follow-up to #749. Comments and docs only; no behavior change, no generated profile changes. Three claims were wrong, all measured against sandbox-exec: 1. "An allow can never take authority back from an earlier rule." False - a later filtered allow does override an earlier filtered deny, which is exactly what the shallow-to-deep emission relies on. The actual reason a read-only path needs an explicit (deny file-write* network-bind network-outbound ...) is narrower: the read-only allow names only file-read*, so it says nothing about write or socket operations and cannot displace a broader grant. 2. "deniedPaths is emitted after the network rules so it also overrides the unfiltered (allow network-outbound)." The override is real, but it does not depend on that ordering - an unfiltered rule never overrides a path-filtered one. Stating a constraint that does not exist invites someone to "fix" the ordering on a false premise. 3. "network-inbound is required in addition to network-bind before the kernel will accept listen()." Backwards. network-inbound (local ip) alone -> LISTEN_OK network-bind (local ip) alone -> bind OK, LISTEN_DENIED (EPERM) neither -> BIND_DENIED (EPERM) network-inbound governs listen() and covers the bind(); it is network-bind that is insufficient on its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * docs(seatbelt): explain the read-only strip at the site reviewers flagged Both review passes on #749 landed on the same line - the read-only network-outbound strip - and asked whether the later unfiltered (allow network-outbound) defeats it. It does not, but nothing at that site said so; the comment there only claimed "an allow never denies", which is the phrasing that fed the wrong model in the first place. Spell it out where the question gets asked: the deny survives because last-match-wins applies between rules that carry a filter, and an unfiltered rule does not override a path-filtered one. Points at the test that pins it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test(seatbelt): stop pinning order against the unfiltered outbound allow Review feedback: two tests still enforced the ordering constraint this PR documents as nonexistent - a path-filtered deny is not overridden by an unfiltered allow in either direction, so asserting the deny is emitted after (allow network-outbound) encodes a constraint that does not exist. - denied_paths_deny_outbound_after_unfiltered_allow -> denied_paths_deny_outbound_under_default_allow: keeps the real guarantee (a denied subtree still denies network-outbound under defaultPolicy: "allow") and drops the order assertion. - readonly_socket_strip_survives_a_default_allow_outbound: drops the same guard, which was added in #749 for the same wrong reason. The order assertions that remain are against *filtered* allows, where last-match-wins genuinely applies and the deny must come second: denied_paths_appear_after_allows_to_override and denied_paths_deny_unix_socket_ops_after_allows. Those stay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 184c815b-d8ac-4bad-8bac-c18ab420e1b7
📖 Description
Fixes two bugs in the macOS Seatbelt backend that made UNIX-domain sockets unusable and silently dropped most filesystem policy rules.
1. AF_UNIX sockets were never permitted
Seatbelt matches AF_UNIX sockets by path —
bind()undernetwork-bind,connect()undernetwork-outbound— whileallowLocalNetworkonly emitsnetwork-inbound (local ip), which covers IP sockets. Any Node toolchain that uses an IPC pipe (tsx, vite, esbuild, jest workers) failed with:Both operations are now granted on
readwritePaths. A UNIX socket is a filesystem object reachable only by processes that can traverse to its path, so using one where writes are already permitted grants no new capability. This is why the filesystem policy governs it rather than the network policy — gating it behindallowLocalNetworkwould force real network ingress on just to run a build.Because the rules are path-scoped they never widen IP binding or IP egress, which stay governed by
defaultPolicyandallowLocalNetwork. Verified empirically: with only a(subpath …)filter present, TCPbind()and TCPconnect()both still returnEPERM.readonlyPathsdeliberately gets neither operation — a socket is a bidirectional channel, not a read.2. Policy paths were emitted verbatim
/etc,/tmp,/varand/homeare symlinks on macOS, and the kernel fully canonicalizes a path before matching it against a profile filter. A rule written against the unresolved path therefore never matches and is silently dead — no error, no diagnostic.This also killed the automatic
$TMPDIRgrant, which resolves to/var/folders/…. Policy paths are now rewritten to their real targets before emission (/etc,/tmp,/var→/private/…;/home→/System/Volumes/Data/home)./Usersneeds no rewriting — it is a firmlink, not a symlink.Escape closed as a side effect
Fixing (2) turns dead deny rules live as well as dead allow rules. While auditing that, an actual bypass was found and closed:
deniedPathsnow also deniesnetwork-outbound.defaultPolicy: "allow"and the remote-proxy fallback emit an unfiltered(allow network-outbound), which previously let the sandboxconnect()to a pre-existing socket inside a denied subtree. A Docker,ssh-agentorgpg-agentsocket is a control plane, so that was an escape.Reproduced end to end — a listener planted in a
deniedPathssubtree, with the sandbox connecting to it:ESCAPE: PWNEDBLOCKED EPERMPath normalization
Policy paths are also folded to a single canonical spelling before emission: redundant separators (
//tmp),.segments (/./tmp) and a trailing/are collapsed. Seatbelt canonicalizes only the accessed path, not the filter, so a rule written with a redundant spelling is dead — and fordeniedPathsthat fails open. Confirmed withsandbox-exec: a deny on(subpath "//private/tmp/x/secret/")lets a write through, while the normalized spelling denies it.A
..segment is rejected as a config error rather than resolved. macOS resolves..physically, after following symlinks, so/tmp/..is/private, not/— resolving it lexically could silently widen an allow rule to/, which is worse than the dead rule it replaces.All of this is pure string work, so
profile_builderstays platform-agnostic and unit-testable on Linux CI.Path precedence
Most-restrictive-wins (
deny>readonly>readwrite) is re-applied to the resolved paths. The shared config parser already applies it, but only to the raw strings — soreadonlyPaths: ["/private/tmp/x"]andreadwritePaths: ["/tmp/x"]survive it as distinct entries and collide only at emission. Seatbelt is last-match-wins and the read-write rule is emitted second, so without this the read-only intent would be silently lost.Rule ordering
readonlyPathsandreadwritePathsare emitted shallow-to-deep, one rule per path, reusingwxc_common::filesystem_resolve::resolve_path_plan— the same plan the Bubblewrap and LXC backends use. Last-match-wins then gives deepest-intent-wins, so a read-only entry nested inside a broader read-write subtree stays read-only. Since anallowcan never take authority back, each read-only path also emits an explicit(deny file-write* network-bind network-outbound …).deniedPathsstays outside that plan and is emitted after the network rules, so it still overrides the unfiltered(allow network-outbound).Security tradeoff
AF_UNIX
connect()is a capability thatfile-write*alone did not grant. A sandbox with a broadreadwritePathsroot can now reach any pre-existing listener underneath it, and a control socket (Docker,ssh-agent,gpg-agent) is a meaningful target. Documented in the backend guide: prefer a narrow read-write root, and put sensitive sockets indeniedPaths, which overrides the grant.Known limitation
..is rejected on this backend only, while the shared parser accepts it — a cross-backend policy using..will fail on macOS and run elsewhere. That is intentional: the alternative is the silently-dead rule this PR exists to remove. Resolving it correctly needsstd::fs::canonicalizeagainst the live filesystem.For the same reason, only root symlinks are resolved. A policy path that traverses a non-root symlink still produces a rule that never matches; for
deniedPathsthat fails open. Fixing it needsstd::fs::canonicalize, which would breakprofile_builder's platform-agnostic purity (it is pure string generation, unit-tested on every host including Linux CI). Left as a separate change — Bubblewrap already solves the equivalent problem withresolve_through_symlinksinbwrap_runner.rs.🔗 References
docs/macos-support/seatbelt-backend.md— updated with the new rule tables, a#### Path resolutionsection, and a#### UNIX-domain socketssection explaining the rationale and the two intentional asymmetries.🔍 Validation
Automated
cargo test -p seatbelt_common→ 62 passed, 0 failed (was 45 before; 17 new tests cover socket ops, readonly exclusion, deny ordering vs. the unfiltered outbound allow, root-symlink resolution incl. whole-segment matching and idempotence, lexical spelling normalization /..rejection, resolved-path precedence for aliased spellings, and depth ordering in both nesting directions)cargo clippy -p seatbelt_common --all-targets -- -D warnings→ cleancargo test -p seatbelt_common -p wxc_common -p mxc_engine -p mxc-sdk -p mxc_darwin→ all green, 0 failedManual, with a real
mxc-exec-macbuilddefaultPolicy: "block", raw$TMPDIRinreadwritePaths, bind + connect on a.pipe(the reported failure)LISTEN_OK/CONNECT_OK/SERVER_GOT pingmainLISTEN_FAIL EPERMdeniedPathssubtreeEPERMdeniedPathssubtreeEPERM(ESCAPE: PWNEDbefore)//private/tmp/x/secret/readonlyPaths: ["/private/tmp/x"]+readwritePaths: ["/tmp/x"]EPERMon write (was writable before resolved-path precedence)readonlyPaths: ["/private/tmp/x/secret"]nested inreadwritePaths: ["/tmp/x"]EPERMon write (was writable before depth ordering)readwritePaths: ["/private/tmp/x/build"]nested inreadonlyPaths: ["/tmp/x"]listen()underblock, noallowLocalNetworkEPERM— no IP-bind leaklisten()underblockwithallowLocalNetwork: trueTCP_LISTEN_OK— existing behavior preservedconnect()with only path-scoped outbound rulesEPERM— no IP-egress leakBypass probes —
..,//, trailing slash, and the/tmp→/private/tmpalias were each attempted against a denied subtree; all correctly denied (the kernel canonicalizes before rule evaluation).Pre-existing, unrelated build breakage on macOS (
nanvix_commonE0425,windows-future) was confirmed present onmain, hence the targeted-pcrate selection above rather than--workspace.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see docs/pull-requests.md)📋 Issue Type
Microsoft Reviewers: Open in CodeFlow