Skip to content

Keep the default write paths out of a read-denied directory - #506

Open
ronleizrowice-ant wants to merge 6 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/default-write-paths-under-denyread
Open

Keep the default write paths out of a read-denied directory#506
ronleizrowice-ant wants to merge 6 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/default-write-paths-under-denyread

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Problem

getDefaultWritePaths() adds ten paths to every write allow-list. Eight are what the sandbox itself needs (the child's stdio, /tmp/claude). Two are conveniences under the home directory that the caller never asked for: ~/.npm/_logs and ~/.claude/debug.

With denyRead: ['~'] (or ['~/.npm'], or a credential file deny on ~/.claude), Linux mounts a tmpfs over the denied directory and then binds the convenience directory back over it read-write, so its host contents are readable again inside the sandbox and writes land on the host. On macOS the directory stays writable under the read deny.

Fix

getDefaultWritePaths({ denyRead, allowRead }) leaves a home convenience directory out when a denyRead entry names it or a directory above it. The explicit deny wins over the implicit allow; a caller who wants the directory writable lists it in allowWrite.

  • Only the two home directories are ever left out. The /dev nodes and /tmp/claude are kept whatever is read-denied, so denyRead: ['/'] with allowRead carve-outs behaves as before.
  • An allowRead entry beneath the covering deny keeps the directory. With denyRead: ['~'], allowRead: ['~/.claude'] the caller has already made it readable, nothing is bound back over a deny, and the write allow stays.
  • Entries are passed as configured. dir/** counts as dir; a glob covers a directory when it matches that directory or one above it (the same denyGlobRegex the macOS profile is built from, now shared). Nothing is listed from disk and no pattern is compiled from a file name. On Linux, where the backend expands globs, a glob that matches nothing on disk still counts (this only ever drops a convenience path), and a glob whose match is a symlink to one of these directories is not seen. A glob allowRead entry is not counted as re-opening anything.
  • getFsWriteConfig() stays a plain getter. It and wrapWithSandbox() go through one helper that adds the credential file denies from the pure getCredentialDenyReadPaths(): no credential masking, no glob expansion, no new way to throw. The zero-argument getDefaultWritePaths() is the constant list and touches neither the cwd nor the disk.
  • The Linux violation monitor keeps the full defaults. A directory a wrap leaves out sits under the read deny's writable tmpfs, so a write there succeeds and is not a violation.
  • normalizePathForSandbox() reads the cwd only for a relative path.

No change for a config that does not read-deny one of the two directories. Not behind a flag. The README gains a sentence on when the two directories are dropped.

Not addressed, deliberately: a deny spelled in different case than the on-disk name on a case-insensitive volume does not cover the directory (Seatbelt matches case-sensitively, so this is consistent with what is enforced), and Windows enforcement never used the default write paths.

Testing

bun test test/sandbox/default-write-paths.test.ts (23 cases on Linux):

  • each deny spelling by name (<home>, ~/, ~/**, <home>/**, /, /**), a glob over the parent directory, siblings that must not match, an entry whose brackets are not a valid class, and the allowRead cases;
  • an invariant that nothing under a read-denied home survives, so a third home default added later cannot bring the leak back;
  • in a process with its own HOME, where both directories exist: getFsWriteConfig() and the wrapped command for a filesystem deny, a credential file deny and a per-call allowRead; and a covering deny when ~/.npm/_logs does not exist yet (on macOS that HOME is under /var, which normalizes to /private/var);
  • on Linux, a file named backup[2024-01-15].env under a denyRead glob and a credential mask with onExtractNoMatch: "error" leave getFsWriteConfig() and the wrap alone.

Removing any one of the call-site arguments, the credential denies, the regex fallback or the allowRead exemption turns the file red.

Run end to end on Linux with the built CLI under real bubblewrap and a HOME holding host content in both directories: with denyRead: ['~/.npm'] main prints the host log and its write lands on the host; this branch prints "No such file" and the write stays on the tmpfs. ~/**, ~/.n*, a credential deny on ~/.claude, denyRead: ['/'] with carve-outs (/dev/null and /tmp/claude writable), updateConfig() and a per-call override behave the same way. The macOS profile output is covered by the macOS CI legs only.

Full suite on Linux: 1076 pass, 8 fail, 230 skip; the 8 failures are the same 8 that fail on main on that machine (they need the seccomp helper binary built).

~/.npm/_logs and ~/.claude/debug are added to every write allowlist as a
convenience; under a denyRead that covers the home they were bound back
over the deny on Linux, readable and writable, and writable on macOS.
getDefaultWritePaths now drops a default that lies at or under a
read-denied directory; a caller who wants it lists it in allowWrite.
getDefaultWritePaths compares each recommended path, as listed and as
normalizePathForSandbox spells it, against the normalized denyRead
entries through one at-or-under helper instead of four inline clauses.
wrapWithSandbox computes the effective denyRead (configured entries
unioned with credential deny paths) once, ahead of the write config, and
hands that to getDefaultWritePaths rather than spelling the
customConfig/config fallback a second time.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could getDefaultWritePaths() account for denyRead globs too? On Linux, ~/.npm/* is expanded later to a concrete _logs deny, but this helper ignores non-trailing globs and still adds _logs to allowOnly, which can bind it back over the read deny. That seems to leave the same fail-open path for glob-based policies.

…orce

getDefaultWritePaths(denyRead) now prunes just ~/.npm/_logs and
~/.claude/debug. The device nodes and /tmp/claude are what the sandbox
itself needs (stdio, TMPDIR) and stay whatever is read-denied, so a
denyRead of '/', '/tmp' or '/dev' behaves as it did before.

A 'dir/**' entry is stripped before it is normalized, so '~/**' and
'<home>/**' prune like '~'; the earlier code stripped the suffix only for
its glob test and then compared the unstripped spelling, which matched
nothing. A glob that reaches the function unexpanded prunes a convenience
when it matches that directory or one above it, instead of being skipped.

All three call sites pass the resolved read-deny list: wrapWithSandbox
its expanded list (credential denies unioned, globs expanded on Linux),
getFsWriteConfig and the Linux violation monitor getFsReadConfig().denyOnly,
so the reported write config agrees with what a wrap enforces. Containment
goes through the shared isAtOrUnder.

Tests: the '~/**' spellings, a glob over the parent directory, the
sandbox's own paths under root-level denies, and one case through
SandboxManager (a filesystem deny and a credential file deny) that fails
if a call site stops passing the list.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Merged main and pushed f6e7f91, which narrows and corrects the pruning:

  • Only ~/.npm/_logs and ~/.claude/debug are ever pruned. The previous revision filtered every default, so denyRead: ['/'] with allowRead carve-outs also lost /dev/null, /dev/stdout, /tmp/claude (the TMPDIR target). There is now a regression test for /, /**, /tmp and /dev.
  • ~/** and <home>/** now match (the /** suffix was stripped only for the glob check, then the unstripped entry was normalized and compared, so it never matched).
  • Linux glob denies prune too: wrapWithSandbox passes its expanded list. A glob left unexpanded (macOS) prunes when it matches the directory or one above it.
  • All call sites use one deny list (credential denies unioned, globs expanded on Linux), so the reported write config and what is enforced agree.
  • Uses isAtOrUnder from sandbox-utils.ts instead of a local closure.
  • Tests: four of the five fail against the previous source; one goes through SandboxManager.initializegetFsWriteConfig()/wrapWithSandbox, so reverting a call site turns the suite red.

Local run (Linux x86-64): eslint, prettier and tsc clean; full npm test shows only the failures main has on the same box.

The subtree-extended regex for a glob deny lived privately in the macOS
backend. It moves, unchanged, to sandbox-utils beside globToRegex and is
exported, so the default-write-path check can ask "does this glob deny
cover this directory" with the same rule the profile is built from.
getDefaultWritePaths() now takes { denyRead, allowRead } as configured,
not an expanded deny list, and the manager feeds it through one helper
that adds the credential file denies from the pure
getCredentialDenyReadPaths(). getFsWriteConfig() is a plain getter again:
it no longer calls getFsReadConfig(), so it runs no credential masking
(which reads and rewrites files, warns, and throws for
onExtractNoMatch: "error"), lists nothing from disk, and compiles no
pattern from a file name. On Linux the expanded list turned a real file
called backup[2024-01-15].env into a regex that failed to compile, out
of every wrapWithSandbox() and getFsWriteConfig().

The check itself:
- returns the constant list, touching neither cwd nor disk, when there
  are no read rules (the exported zero-argument call);
- normalizes each entry once, and spells a home directory from the home
  directory that exists rather than from a leaf that may not, so a HOME
  under /tmp or /var on macOS is covered before ~/.npm/_logs exists;
- matches globs with the shared denyGlobRegex, and compares an entry
  whose brackets are no valid class as the literal it may be;
- keeps a home directory an allowRead entry beneath the covering deny
  re-opens: with denyRead ['~'] and allowRead ['~/.claude'] nothing is
  bound back over a deny, and macOS was losing the write allow it had.
normalizePathForSandbox() reads the cwd only for a relative path, so an
absolute or ~ entry does not throw under Node once the cwd is removed.

The Linux violation monitor goes back to the full defaults. A directory a
wrap leaves out sits under the read-deny's writable tmpfs, so a write
there succeeds; filtering the monitor's list reported it as a violation,
and an initialize()-time snapshot cannot follow per-call or updated
configs anyway. The write config getFsWriteConfig() reports is what
agrees with a wrap; the monitor never did.

The two kinds of default are named constants, the contract is in the
exported function's JSDoc, and the README says when the home directories
are dropped. Tests: each deny spelling by name, the allowRead cases, an
invariant that nothing stays under a read-denied home, and, in a process
with its own HOME where both directories exist, getFsWriteConfig() and
the wrapped command for a filesystem deny, a credential deny and a
per-call allowRead; on Linux, a bracketed file under a denyRead glob and
a failing credential mask leave the getter and the wrap alone.
@ronleizrowice-ant

Copy link
Copy Markdown
Contributor Author

Second round on top of f6e7f91 (ef8b603, 365b95f), from a deeper review. Two of its findings were defects the first version introduced, so please review this head rather than the earlier one.

  • getFsWriteConfig() had stopped being a plain getter. To learn the deny list it called getFsReadConfig(), which runs the credential-mask pipeline (reads real credential files, rewrites the sentinel fakes, warns or throws on onExtractNoMatch) and, on Linux, expands every glob with a recursive readdir. Reproduced: a mask entry with onExtractNoMatch: "error" made the getter throw where main returns; one glob with 2,000 matches took it from 0 ms and no syscalls to ~115 ms and 4,003 realpaths per call.
  • A file name could make every wrap throw. On Linux the check received already-expanded concrete paths and re-read any containing [ ] * ? as a glob, so a real file named backup[2024-01-15].env under a denyRead glob threw SyntaxError out of getFsWriteConfig() and wrapWithSandbox() (reproduced).

Both go away with one change: the check takes the entries as configured (getDefaultWritePaths({ denyRead, allowRead })), through one helper that adds the credential file denies from the pure getCredentialDenyReadPaths(). Nothing is listed from disk, nothing is compiled from a file name, and an invalid bracket class in a configured entry falls back to a literal compare instead of throwing.

Also in this round: an allowRead entry beneath the covering deny keeps the directory (denyRead: ['~'], allowRead: ['~/.claude'] used to lose ~/.claude/debug's write allow on macOS although the caller had re-opened it); the Linux violation monitor is back to the full defaults (a directory a wrap leaves out sits under the deny's writable tmpfs, so a write there succeeds and is not a violation); the zero-argument call is a constant again (it had started calling process.cwd() and threw under Node once the cwd was removed); the resolved spelling is built from the home directory, which exists, rather than from a leaf that may not yet; denyGlobRegex is shared with the macOS backend instead of re-derived; the two kinds of default are named, documented on the exported function, and pinned by an invariant test.

23 cases, seven of eight mutations turn the file red (the survivor is behaviourally equivalent). eslint, prettier, tsc clean; full npm test shows only the failures main has on the same box. Driven end to end with the built CLI under real bubblewrap against main as control.

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.

2 participants