test(seatbelt): e2e guard for un-resolved $TMPDIR read-write grants - #659
Conversation
On macOS `$TMPDIR` is the per-user `_CS_DARWIN_USER_TEMP_DIR` (`/var/folders/<a>/<b>/T/`); its siblings `C` (`_CS_DARWIN_USER_CACHE_DIR`) and `0` (misc) share the same per-user container but were left unwritable when only the temp leaf was granted, so sandboxed tools that stage under the per-user cache were denied. Widen a read-write grant on a `T`/`C`/`0` leaf to its enclosing `/var/folders/<a>/<b>` container in the Seatbelt profile builder, so all three siblings are covered by one grant. The guard is strict — only a direct `T`/`C`/`0` child of a genuine two-segment per-user container (optionally under `/private`, the post-canonicalization form) qualifies, so a grant can never widen up to `/var/folders` (every user) or `/`. `deniedPaths` still override, since denies are emitted after allows (last-match-wins). Applied unconditionally in the platform-agnostic profile builder because a Seatbelt profile is always a macOS artifact regardless of build host, matching the existing `guiAccess` `/private/var/folders` grant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e17ae47-34f1-4c15-8f22-8eb86f7b12a5 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adjusts the macOS Seatbelt profile generator to broaden certain readwritePaths entries that target per-user Darwin temp/cache leaves under /var/folders/<a>/<b>/{T,C,0} so the enclosing per-user container is granted instead, preventing cache-related write denials in sandboxed tools. The change is implemented in the profile builder so it benefits all macOS backend consumers (CLI/SDKs/FFI) without duplicating Darwin path knowledge.
Changes:
- Widen
readwritePathsgrants for/var/folders/<a>/<b>/{T,C,0}(and/private/...) to/var/folders/<a>/<b>in the Seatbelt profile builder. - Add unit tests validating widening behavior and non-matching cases.
- Document the widening behavior in the Seatbelt backend docs.
Show a summary per file
| File | Description |
|---|---|
| src/backends/seatbelt/common/src/profile_builder.rs | Adds Darwin temp/cache leaf widening logic in write_filesystem_allow plus new helper + tests. |
| docs/macos-support/seatbelt-backend.md | Documents the readwritePaths widening behavior and its safety constraints. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Low
darwin_temp_container_grant accepted any non-empty <a>/<b> segment, so a path-traversal input like /var/folders/ab/../T widened to /var/folders/ab/.. (effectively /var/folders), over-broadening the grant and contradicting the documented "never widen up to /var/folders or /" guarantee. Genuine Darwin per-user container segments are never . or .., so reject those (and empty) segments before widening, and cover the traversal cases in the rejection test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e17ae47-34f1-4c15-8f22-8eb86f7b12a5 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Express the .-/..-rejection with matches!(*s, "." | ".."), matching the leaf check in the same function. Behavior-preserving. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e17ae47-34f1-4c15-8f22-8eb86f7b12a5 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Addresses review feedback: widening a per-user Darwin temp/cache leaf grant to the whole '/var/folders/<a>/<b>' container made every directory under it writable, not just the T/C/0 siblings, so callers lost least-privilege access to other subdirs. Expand instead to exactly the three well-known siblings (.../T, .../C, .../0) and never grant the container itself. Also add the requested test: granting the T leaf allows the C sibling, and a deniedPaths entry on C still wins via last-match ordering (allow-then-deny). Rename darwin_temp_container_grant -> darwin_temp_sibling_grants (now returns the three sibling paths) and update the backend doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e17ae47-34f1-4c15-8f22-8eb86f7b12a5 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
|
Huzaifa Danish (@huzaifa-d) thank you, resolved. |
Gudge (MGudgin)
left a comment
There was a problem hiding this comment.
Adversarial review — 10 axes
Ran a multi-axis adversarial review of this PR (security, reliability, performance, maintainability, correctness, testability, test coverage, documentation-drift, cross-platform-parity, threat-model), each axis in an isolated context on a non-Claude model, then consolidated. Findings: 6 High, 6 Medium, 3 Low. Correctness and performance both came back clean, with documented traces.
Credit where it's due: the underlying complaint is real — granting $TMPDIR and having the sibling cache stay unwritable is a genuine usability bug — and darwin_temp_sibling_grants is the strongest part of this PR. A dedicated correctness pass traced it character by character (doubled slashes in every position, multiple trailing slashes, /private boundary handling, "/" / "/private" / "", ./.. in both segments) and found no parser defects. The guard genuinely fails closed.
My concerns are about the approach, not the parsing.
1. The serializer silently rewrites the caller's security policy
write_filesystem_allow's job is to render a policy; this makes it also mutate one. Three consequences, all found independently:
- A sibling explicitly listed in
readonlyPathscomes out read-write (found by cross-platform-parity and security separately). The RO block is emitted at lines 153-161, the expanded RW block at 163-180, and Seatbelt is last-match-wins — soreadonlyPaths: [".../C"] + readwritePaths: [".../T"]grants write toC.deniedPathscan't fix it, because it denies reads too. This is a straightforward privilege bug and it's my main blocker. - The caller can't see or disable the expansion. The
deniedPathsescape hatch assumes the caller knows about a transformation that has no flag, no schema field, no log line, and no API surface. - Every SDK inherits it silently — Node, C#/FFI, Rust — with no version signal.
2. It may not work where it matters
Every other hardcoded macOS path in this file is /private-anchored (lines 120-122, 329, 445-446, 456) because Seatbelt matches resolved paths and /var is a symlink to /private/var. The new grants instead echo whichever spelling the caller passed. For the common un-prefixed $TMPDIR form, the emitted C/0 rules plausibly match nothing at runtime — i.e. the feature silently no-ops. Three axes converged on this (reliability, maintainability, security-via-deny-forms).
Nothing would catch it: all four tests assert on substrings of a generated string, and nothing anywhere feeds a profile to sandbox_init.
Findings that are out of diff range (no inline anchor)
Medium (security) — the deniedPaths carve-out is path-form sensitive. write_filesystem_deny emits denies verbatim with no canonicalization, so a deny written /private/var/folders/ab/cd/C will not override an expanded allow written /var/folders/ab/cd/C, or vice versa. The new deny test only exercises matching forms. If deniedPaths is the documented escape hatch for this expansion, it needs to work across both spellings.
High (documentation-drift) — the widening isn't discoverable where policy authors look. readwritePaths is a cross-backend schema field, but this is documented only in the macOS backend deep-dive. Unchanged and now incomplete: docs/schema.md:113-119, the wire-model source src/core/wxc_common/src/wire.rs:253-259 (and therefore the generated schemas/dev/), sdk/node/src/types.ts JSDoc, sdk/dotnet/README.md, and the Rust SDK docs. Someone writing policy JSON or calling spawnSandbox has no way to learn their $TMPDIR grant became three grants.
Medium (documentation-drift) — no version or release signal. Sandbox-policy semantics changed with no schema-version bump, no entry in sdk/node/CHANGELOG.md, and no release note, while docs/sandbox-policy/v1/policy.md:75-80 tells consumers a version pins behavior. There's no way to tell which release started widening permissions.
Medium (test coverage) — the readonlyPaths non-expansion isn't locked in. The existing readonly test uses unrelated paths, so nothing would catch sibling expansion being applied there later.
What I'd want before approving
- Make it opt-in — a policy flag, or require the caller to list the siblings. Silent widening of a security boundary is the core objection.
- Never widen a sibling the caller named in
readonlyPathsordeniedPaths— resolve intents before emission withdenied > readonly > readwrite. - Normalize to
/private/var/folders/...to match this file's existing convention. - One macOS
sandbox-exectest granting only the real host.../Tand writing under.../C. That single test covers both the spelling risk and profile loadability.
If (1) is unappealing, I'd settle for the expansion staying unconditional but strictly subordinate to explicit caller intent, plus the docs moving up to the shared readwritePaths contract. Happy to discuss — and if I've misread the Seatbelt path-resolution semantics in (3), please push back, that one is the least certain of the set.
Correction to my review — two findings re-attributedI ran an attribution pass over my own review, checking every finding that cited code outside this PR's diff against 1. Withdrawn — "denied-path carve-outs are path-form sensitive" (was Medium, security)I claimed the That is pre-existing and not introduced by this PR:
So this PR neither introduces nor worsens it. Please disregard that finding. It's a reasonable repo-level issue but it isn't yours, and it shouldn't be a condition on this merge. 2. Rescoped and downgraded — "
|
Seatbelt evaluates `subpath` filters against the resolved path the kernel sees. On macOS `/etc`, `/tmp`, and `/var` are symlinks into `/private`, so a rule emitted as `(subpath "/var/folders/...")` matches nothing at all. That made every caller-supplied path under those roots a silent no-op: the grant was emitted, the profile loaded, and the access was still denied. It bites hardest on `$TMPDIR`, whose ordinary spelling is the per-user `/var/folders/<a>/<b>/T/`, so a caller passing `$TMPDIR` straight through to `readwritePaths` got no usable grant. Verified directly with `sandbox-exec`: a `/var`-spelled grant denies the write, the `/private/var` spelling allows it. The existing filesystem e2e tests missed this because they all `fs::canonicalize` the path first, which resolves the symlink themselves. Normalize the three roots on every caller-supplied path — `readonlyPaths`, `readwritePaths`, and `deniedPaths` alike — so last-match-wins ordering between the lists stays meaningful regardless of which spelling each entry used. The rewrite is purely lexical and replaces only a leading whole component, so `/variable` and paths already under `/private` are untouched. This replaces the previous `T`/`C`/`0` sibling widening, which addressed the symptom rather than the cause. With the root fixed a caller can simply name the per-user cache sibling and have it work, so the implicit expansion is no longer needed — removing it also drops its least-privilege costs: it could silently upgrade a sibling the caller had put in `readonlyPaths` to read-write, and it granted the per-user cache and `_CS_DARWIN_USER_DIR` without the caller asking. No path is expanded beyond what the caller named. Adds a macOS e2e regression test that passes the *un-resolved* `/var` spelling of the host `$TMPDIR` and asserts a sandboxed write under it succeeds, covering both profile loadability and the grant actually matching. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> # Conflicts: # src/backends/seatbelt/common/src/profile_builder.rs
Upstream landed the macOS root-symlink resolution independently (with a more complete implementation than this branch had: lexical normalization, `..` rejection, `/home`, and denied > readonly > readwrite precedence), so the builder change here is dropped in favor of it. The e2e test survives, since nothing upstream covers it. Every other filesystem test in the seatbelt characterization suite calls `fs::canonicalize` on its temp dir first, which resolves the symlink itself — so none of them exercises the un-resolved `/var` spelling that `$TMPDIR` actually has, and none would catch a regression in the resolution. The builder's own unit tests assert on generated profile text and so cannot show the kernel honors the result. Reword the doc comment to describe the test as a guard on `resolve_macos_root_symlinks` rather than on this branch's removed change. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
src/testing/wxc_e2e_tests/tests/e2e_seatbelt_characterization.rs:272
- The current PR diff adds only this E2E test, while the normalization implementation and documentation are already present on the base branch. The title/body therefore inaccurately claim that this PR implements the rewrite and updates the documentation. Please update the PR metadata to describe this as regression coverage, or include the implementation changes it claims to deliver.
/// End-to-end guard for the macOS root-symlink resolution in
/// `seatbelt_common::profile_builder` (`resolve_macos_root_symlinks`).
src/testing/wxc_e2e_tests/tests/e2e_seatbelt_characterization.rs:303
- This returns success without exercising the regression whenever
TMPDIRis overridden to/tmp,/private/..., or another location, so the new E2E guard can silently become a no-op in CI. Use a guaranteed unresolved macOS alias such as writable/var/tmp(with the existing unique child directory) instead of conditionally skipping.
let temp_root = std::env::temp_dir();
if !temp_root.starts_with("/var") {
return;
}
- Files reviewed: 1/1 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Gudge (MGudgin)
left a comment
There was a problem hiding this comment.
Approving — and correcting my earlier correction
The PR I reviewed no longer exists in any meaningful sense: the sibling-expansion approach is gone, and what remains is a single 66-line e2e guard. That is the right outcome, and the underlying bug turned out to be both deeper and more general than what the original change was reaching for.
For the record, tracing where everything went:
| My finding | Disposition |
|---|---|
High — expanded read-write grant silently upgrades a sibling the caller listed in readonlyPaths |
Moot, and fixed more generally. Sibling expansion is gone; ResolvedPaths::from_policy in main now re-applies deny > readonly > readwrite after path resolution, so two spellings of the same path can no longer let the weaker grant win |
High — /var grants may not match Seatbelt's resolved /private/var |
Confirmed and fixed in main (resolve_macos_root_symlinks, #749) |
| High — no test proves the profile loads or that the write actually succeeds | This PR |
Silent widening into .../C / .../0 with no opt-out; docs/schema/SDK disclosure; missing version signal; per-user container validation; parser edge cases |
Moot — no policy rewriting remains |
I got one of my own corrections wrong
In my correction comment I downgraded the /var vs /private/var finding from High to Medium and wrote that the "feature may be a no-op" claim was "overstated without a macOS host to confirm it." That was wrong, and the reasoning that led me there is worth naming.
I applied a counter-evidence test: if un-resolved /var grants really never matched, the pre-existing $TMPDIR grant would already be broken — and since it evidently works, my claim must be too strong. Main's own doc comment now settles it the other way:
a rule written against the unresolved path is dead:
(subpath "/tmp/work")never matches, because the kernel only ever sees/private/tmp/work. That silently voided every policy path under these roots — including the automatic$TMPDIRgrant, which resolves to/var/folders/...on macOS.
So the $TMPDIR grant was broken. My test assumed a visible symptom would exist if the bug were real — but the visible symptom was sitting in this PR's own description ("tools that stage under the per-user cache were denied"). I read that as evidence for the sibling theory instead of evidence for the resolution bug, and then used its apparent absence to argue myself out of the correct finding. The original High stood; my downgrade did not.
Which also reframes the original diagnosis: the sibling expansion was treating a symptom. The cache directory was not unwritable because siblings went ungranted — it was unwritable because no /var/... grant matched at all. Landing the resolution fix upstream and reducing this PR to a guard is a strictly better outcome than the change I first reviewed.
On the test itself
This is the test I asked for, and slightly better. It uses std::env::temp_dir() so there is no machine-specific container id, it asserts both that the profile loads and that the write succeeds, and the doc comment calls out the reason this class of bug survived so long:
It deliberately does not canonicalize the path. Every other filesystem test in this file calls
fs::canonicalizefirst, which resolves the symlink itself — so none of them would notice this class of bug.
That last point is the valuable part. A test suite that canonicalizes its inputs cannot see a resolution bug, and stating that inline stops someone "tidying" it later. The early return when temp_dir() is not under /var is the right call too — it keeps the test honest on hosts where the spelling would not exercise the regression.
Not verified locally: the file is #![cfg(target_os = "macos")] and I am on Windows, so I read it rather than ran it.
Thanks for taking the redesign rather than patching the original approach.
6d15a03
into
microsoft:main
📖 Description
Background
On macOS, Seatbelt evaluates
subpathfilters against the resolved path the kernel sees./etc,/tmp, and/varare symlinks into/private, so a rule emitted as(subpath "/var/folders/…")matches nothing at all — the access is checked as/private/var/folders/….That silently voided every caller-supplied policy path under those roots: the grant was emitted, the profile loaded fine, and the access was still denied. It bites hardest on
$TMPDIR, whose ordinary spelling is the per-user_CS_DARWIN_USER_TEMP_DIR(/var/folders/<a>/<b>/T/).Verified directly against
sandbox-execwhile investigating review feedback: with an otherwise identical profile, a grant spelled/var/folders/<container>/Cdenies the write, while/private/var/folders/<container>/Callows it. The same holds for/tmpvs/private/tmp.Upstream
mainnow handles this inresolve_policy_path/resolve_macos_root_symlinks, together with lexical normalization,..rejection,/home, anddenied > readonly > readwriteprecedence. That implementation is a superset of what this branch had, so this branch now simply takes it.What is left in this PR
A single macOS-gated e2e test,
seatbelt_honors_uncanonicalized_var_readwrite_path, insrc/testing/wxc_e2e_tests/tests/e2e_seatbelt_characterization.rs.It passes the host
$TMPDIR(viastd::env::temp_dir(), so no machine-specific container id) as the solereadwritePathsentry without canonicalizing it, then runs a sandboxed command that writes there and asserts both a zero exit and that the file exists.It is worth keeping on top of upstream's unit tests because it closes a gap none of them cover:
fs::canonicalizeon its temp dir first, which resolves the symlink itself. So none of them ever exercises the un-resolved/varspelling that$TMPDIRactually has, and none would catch a regression in the resolution.It early-returns when
env::temp_dir()is not the/varspelling, so it is a clean no-op rather than a false pass on a host where the premise does not hold.🔗 References
No linked issue. Supersedes this branch's earlier builder change; the fix itself now lives in upstream
main.🔍 Validation
On a macOS host (
aarch64-apple-darwin), run fromsrc/, against the merged tree:cargo test -p seatbelt_common— 63 passed (upstream's suite, unmodified).cargo test -p wxc_e2e_tests --test e2e_seatbelt_characterization— 9 passed, including the new test.cargo fmt --all -- --check— clean.cargo clippy -p wxc_e2e_tests --all-targets -- -D warnings— clean.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (no dependency change)📋 Issue Type
Microsoft Reviewers: Open in CodeFlow