Skip to content

fix(seatbelt): allow AF_UNIX sockets and resolve symlinked root paths - #749

Merged
Soham Das (SohamDas2021) merged 5 commits into
microsoft:mainfrom
caarlos0:sockets
Aug 5, 2026
Merged

fix(seatbelt): allow AF_UNIX sockets and resolve symlinked root paths#749
Soham Das (SohamDas2021) merged 5 commits into
microsoft:mainfrom
caarlos0:sockets

Conversation

@caarlos0

@caarlos0 Carlos Alexandro Becker (caarlos0) commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

📖 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 pathbind() under network-bind, connect() under network-outbound — while allowLocalNetwork only emits network-inbound (local ip), which covers IP sockets. Any Node toolchain that uses an IPC pipe (tsx, vite, esbuild, jest workers) failed with:

Error: listen EPERM: operation not permitted /var/folders/…/T/tsx-501/51325.pipe

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 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. Verified empirically: with only a (subpath …) filter present, TCP bind() and TCP connect() both still return EPERM.

readonlyPaths deliberately gets neither operation — a socket is a bidirectional channel, not a read.

2. Policy paths were emitted verbatim

/etc, /tmp, /var and /home are 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 $TMPDIR grant, 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). /Users needs 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: deniedPaths now also denies network-outbound. defaultPolicy: "allow" and the remote-proxy fallback emit an unfiltered (allow network-outbound), which previously 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.

Reproduced end to end — a listener planted in a deniedPaths subtree, with the sandbox connecting to it:

Result
before ESCAPE: PWNED
after BLOCKED EPERM

Path 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 for deniedPaths that fails open. Confirmed with sandbox-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_builder stays 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 — so readonlyPaths: ["/private/tmp/x"] and readwritePaths: ["/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

readonlyPaths and readwritePaths are emitted shallow-to-deep, one rule per path, reusing wxc_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 an allow can never take authority back, each read-only path also emits an explicit (deny file-write* network-bind network-outbound …).

deniedPaths stays 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 that file-write* alone did not grant. A sandbox with a broad readwritePaths root 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 in deniedPaths, 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 needs std::fs::canonicalize against 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 deniedPaths that fails open. Fixing it needs std::fs::canonicalize, which would break profile_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 with resolve_through_symlinks in bwrap_runner.rs.

🔗 References

  • docs/macos-support/seatbelt-backend.md — updated with the new rule tables, a #### Path resolution section, and a #### UNIX-domain sockets section explaining the rationale and the two intentional asymmetries.

🔍 Validation

Automated

  • cargo test -p seatbelt_common62 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 → clean
  • cargo test -p seatbelt_common -p wxc_common -p mxc_engine -p mxc-sdk -p mxc_darwin → all green, 0 failed

Manual, with a real mxc-exec-mac build

Scenario Result
defaultPolicy: "block", raw $TMPDIR in readwritePaths, bind + connect on a .pipe (the reported failure) LISTEN_OK / CONNECT_OK / SERVER_GOT ping
same scenario on main LISTEN_FAIL EPERM
bind inside a deniedPaths subtree EPERM
connect to a pre-existing socket inside a deniedPaths subtree EPERM (ESCAPE: PWNED before)
deny written as //private/tmp/x/secret/ denied (write went through before normalization)
readonlyPaths: ["/private/tmp/x"] + readwritePaths: ["/tmp/x"] EPERM on write (was writable before resolved-path precedence)
readonlyPaths: ["/private/tmp/x/secret"] nested in readwritePaths: ["/tmp/x"] EPERM on write (was writable before depth ordering)
readwritePaths: ["/private/tmp/x/build"] nested in readonlyPaths: ["/tmp/x"] write succeeds — deeper intent still wins
TCP listen() under block, no allowLocalNetwork EPERM — no IP-bind leak
TCP listen() under block with allowLocalNetwork: true TCP_LISTEN_OK — existing behavior preserved
TCP connect() with only path-scoped outbound rules EPERM — no IP-egress leak

Bypass probes.., //, trailing slash, and the /tmp/private/tmp alias were each attempted against a denied subtree; all correctly denied (the kernel canonicalizes before rule evaluation).

Pre-existing, unrelated build breakage on macOS (nanvix_common E0425, windows-future) was confirmed present on main, hence the targeted -p crate selection above rather than --workspace.

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task
Microsoft Reviewers: Open in CodeFlow

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>
Copilot AI balanced review requested due to automatic review settings August 5, 2026 16:49
@caarlos0

Copy link
Copy Markdown
Collaborator Author

Let me know if y'all think that the socket listen should be behind a new option.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/backends/seatbelt/common/src/profile_builder.rs
Comment thread src/backends/seatbelt/common/src/profile_builder.rs Outdated
Comment thread docs/macos-support/seatbelt-backend.md Outdated
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>
Copilot AI review requested due to automatic review settings August 5, 2026 17:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/backends/seatbelt/common/src/profile_builder.rs
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>
Copilot AI review requested due to automatic review settings August 5, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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"] and readonlyPaths: ["/private/tmp/secret"] now produce a live broad read-write rule after the read-only rule; because the read-only rule does not deny file-write*, writes and the new socket operations remain allowed in secret. 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 new network-outbound rule 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>
Copilot AI review requested due to automatic review settings August 5, 2026 18:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/backends/seatbelt/common/src/profile_builder.rs
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>
Copilot AI review requested due to automatic review settings August 5, 2026 18:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 only network-inbound, and with this PR's validation that default-block plus allowLocalNetwork can 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. Emitting deniedPaths last 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.

@SohamDas2021
Soham Das (SohamDas2021) merged commit 5b1bc72 into microsoft:main Aug 5, 2026
20 checks passed
// denies — so the removal has to be explicit.
write_path_rule(
out,
"deny file-write* network-bind network-outbound",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-inbound governs listen() on its own; network-bind alone 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.

Carlos Alexandro Becker (caarlos0) added a commit to caarlos0/mxc that referenced this pull request Aug 5, 2026
…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>
Carlos Alexandro Becker (caarlos0) added a commit to caarlos0/mxc that referenced this pull request Aug 5, 2026
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>
Soham Das (SohamDas2021) pushed a commit that referenced this pull request Aug 5, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants