Repository navigation
Sandbox: the agent's home is read-only to a project command, so it can't plant config for a later agent-user process; harness-config review locked (#798) [PROTECTED: owner merges] - #899
Conversation
…owaway caches are writable A project command runs as the agent user inside bubblewrap (#713), with the agent's home writable apart from the masked credentials. One command could plant ~/.bashrc, ~/.profile, ~/.gitconfig, ~/.npmrc, ~/.ssh/config or a ~/.local/bin entry, and a later agent-user process outside the sandbox (an admin's login shell, git, the agents' own claude) would read and act on it: a route out of the sandbox (#798). The wrapper now binds the home read-only after the directories it binds onto themselves and before the masks. ~/.cache and ~/.npm are each an empty tmpfs that is gone when the command ends (made in the home first if absent, since npm fails on a read-only cache; never over a symlink). Nothing a command writes in the home survives it. A home that isn't a directory, or is /, is refused: there would be no home to make read-only. On macOS the profile denies every write under the home. The read-only home is the choice over a clean HOME for unsandboxed agent-user processes: it closes the write side for every reader, including ones the engine doesn't start (an admin's login shell, cron), and the agents' own claude can't run with a clean HOME (its login is in ~/.claude). Existing tests changed: the rename test's renames under the home are now refused as read-only (a hide path outside the home keeps the busy case), and the unsearchable-directory tests make the directory unsearchable as the agent user outside the sandbox, since a sandboxed command no longer can (asserted). Test (fails without): test/project-command-home.test.ts, 6 of 6 fail on the code before this change. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…o's review.yaml says harness-config (the config folder, where tests.yaml says what a project command runs and with what environment; .claude/**; CLAUDE.md) was L3 only because the default review.yaml lists it. A repo's review.yaml could leave it out of L3_human, or redefine its globs to nothing, and a tests.yaml change would merge without a human (#798). It is now a locked category like self-modification: always L3 (ALWAYS_L3), always waits (it joins the merge policy's floor through LOCKED_CATEGORIES), in the engine's repo and in managed apps. Its built-in globs always hold; a repo's own additions to it are kept beside them, so no repo that extended it loses a path. Test (fails without): test/harness-config-lock.test.ts, 3 of 3 fail on the code before this change. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… shell `sudo -iu <agent> claude auth status` ran a login shell as the agent user, started by root: it sourced the agent's ~/.profile and ~/.bashrc, files a project command could write before #798. It now runs `sudo -H -u <agent> claude auth status --json` from /, as claude-account.sh already does (claude is /usr/local/bin/claude, tools.sh, on sudo's secure_path). Test (fails without): none; a script line no test runs (it needs root and a real agent user). The read-only home in the previous commit is what stops the planting. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…GORIES The definition sat directly above LOCKED_CATEGORIES, where open PR #886 adds its table marker; moved above the self-modification parts, so the two PRs meet in one line (ALWAYS_L3) instead of two. No change in behaviour. Test (fails without): none; a move. test/harness-config-lock.test.ts still passes. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…s-config locked (#899) Upgrading: what a project sees (throwaway caches, writes elsewhere in the home refused, macOS, the refused homes) and that harness-config always waits. After install: an optional root check for hard links made before. Guardrail changes: what each guardrail does. Test (fails without): none; release notes. test/changelog.test.ts and test/changelog-fragments*.test.ts pass. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…hat harness-config is locked The instruction-eval tests ran a config-folder change through a review.yaml that left harness-config out of L3, so its PR opened without asking. With harness-config always L3 (#798) the owner is asked first: the helper now asserts that (L3, no PR before the answer), approves, and goes on. The merge's high-risk reasons now also name harness-config beside agent-instructions; the expected reasons say so. The changelog says what a repo that left harness-config out of L3 now sees. Test (fails without): none new; these six tests failed on the lock without this update (measured in the full suite: "no pr.opened event") and pass with it. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (independent final check)Head checked: Short version: the design closes the class it names, and the harness-config lock is right. Two CI jobs are red because of this PR, though (one bug in its new CI probe, one existing test it breaks). Outward symlinks are also an open residual, and two of the PR's claims are false as written. Required changes
1. Is the class closed?
2. No regression for existing installsNothing that main closed reopens (inferred, from the diff):
There are new refusals, all fail-closed tightenings the PR lists: a home that is 3. Edits to existing tests (for the weakening gate)No assertion was weakened (inferred; each line was read):
4. The cost (for the owner)Measured, with a scratch project, a temp agent home and no network:
Not measured: Playwright browsers hidden, and npm failing on macOS (inferred, as the PR says). 5. OverlapsMeasured with
6. Fail-without and checks
For the owner (a product trade-off, not a required change)
|
…, or a cache is a link The read-only home was only as good as where its links lead. With ~/.profile, ~/.ssh or ~/.cache as symlinks to agent-writable places outside the home, a sandboxed command wrote through them (the #899 review, measured), and a write to a linked ~/.cache outlived the command. Before mounting, the wrapper now refuses the run, naming every link at once with where it leads and how to remove it as the agent user, when: - an entry directly in the home or directly in ~/.config is a symlink that resolves (readlink -m: every link, `..` and a missing tail) outside the home; - ~/.cache or ~/.npm is a symlink at all (they become throwaway mounts, never through a link); - either directory holds more than LINK_CAP (500) symlinks: refused rather than run with some unchecked. A link leading elsewhere in the home is fine: what it reaches stays read-only. A link already refused on the way to a credential is named once. Links deeper down (~/.ssh/config to /tmp) are still followed: a named residual, with a root check in the release notes. The code comment that said a link's target stays read-only now says where that holds. Test (fails without): test/project-command-home-links.test.ts, 9 of its 10 fail on the code before this commit (the tenth checks that links inside the home still run). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…-users' git check gets a real home The probe asserted the read-only refusal on stdout, but a failed `>>` is reported by the shell before any `2>&1` applies, so it is on stderr: the job failed although the sandbox held (CI on b0c9d27). It now asserts both refusals on stderr and that ~/.bashrc is unchanged, read as the agent user. test/separate-users.test.ts ran its git check with the agent home `/`, which #798 refuses (there is no home to make read-only): it now gets a real temp directory as the home, and keeps `status 0`. Test (fails without): none new; the CI step and the shard test failed at b0c9d27 and are what this fixes. The probe's command, run locally through projectCommand with a temp home, prints both refusals on stderr and leaves ~/.bashrc unchanged. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…r in the home (#899) Upgrading names the new refusal (an outward link directly in the home or ~/.config, a cache that is a link, over 500 links). After install adds a root check for links deeper down that lead outside the home, which the sandbox still follows. The guardrail line says what holds on Linux and what doesn't on macOS. Test (fails without): none; release notes. test/changelog.test.ts and test/changelog-fragments*.test.ts pass. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2: all five points addressed (head
|
Evaluator verdict: CHANGES NEEDED (round 2/2)Delta check of head The outward-link refusal works on the cases round 1 found. But it refuses the CI runner's own skeleton link, so both ci-slow real-user jobs are red, and the probe never reached its #798 lines. It also misses a direct link that leads back into the home through an agent-writable hop outside it. Round-1 required changes
Outward symlinks (measured, head built, temp HOME/TMPDIR, this user as the agent stand-in, bubblewrap)
Required changes
Residual R1 (deeper outward links, and macOS)Acceptable as a named residual for this PR (inferred). After #798, no project command can create a link anywhere in the home, which is read-only. R1 is therefore only about links that already exist, from before this release or from an admin. A recursive per-command scan is affordable but brings false refusals:
The cheaper fix by design is a follow-up: a one-time root scan that refuses or reports outward links, in doctor, macOS follows any outward link: Seatbelt checks the resolved path. This is named in the PR and isn't in CI (inferred). Owner's open cost question (whole home read-only now, against a per-project cache outside the home)These don't conflict (inferred):
Recommendation: keep this PR's read-only home, and track the cache as its own issue. Other checks
Refusals: none. |
…root can change, read-only Round 2 refused every link directly in the home or ~/.config whose final target (readlink -m) was outside the home. That refused the CI runner's skeleton link ~/.ghcup -> /usr/local/.ghcup, so every project command of an agent user made from such a skeleton (useradd --create-home copies /etc/skel) was refused: both real-user ci-slow jobs went red. And it let through ~/.profile -> /x/hop/profile with /x/hop -> ~/dotfiles: resolved inside the home, but the agent can re-aim /x/hop and then write through ~/.profile (the #899 review, measured). Each such link is now walked from the home with walk()'s own rule, in link mode (`hop`): every hop outside the home (a directory on the way, another link, the file it ends at) must be one the agent user neither owns nor can write, and a link is followed only from a directory it can't write. Else the run is refused, naming the link and the hop. In the home a hop counts as fixed (read-only inside the sandbox); above it, as bound onto itself. Allowed: links whose every hop only root can change, and a device (/dev/null); what they reach outside the home is bound read-only in the sandbox (lbinds), so nothing under it is written either. One reaching / or the command's working directory is refused. A dangling link is refused when its way out is the agent's to change. Credential walks are unchanged (hop is a no-op outside link mode). The cache-link refusal is now named once, not twice. Test (fails without): test/project-command-home-hops.test.ts, 3 of 3 fail at 47b7702: the hop bypass and a hop in root's world-writable /tmp are "not refused", and the skeleton's root-owned links are refused (125). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…using it, as walk does for credentials decb681 still refused the CI runner's skeleton link: its target /usr/local/.ghcup is a directory the probe's agent user can write (CI, measured: "through '/usr/local/.ghcup', which the agent user owns or can write"), and on the runners /usr/share is writable by the test user too. A writable directory that isn't the agent's can't be re-aimed once it is pinned, so it is now handled as walk() handles one on a credential's way: - a link found in a directory the agent can write is still refused (it could be re-aimed: the hop bypass); - a directory or file outside the home that the agent owns is still refused (its own files, fail closed); - a directory it can write but doesn't own is pinned (bound onto itself with everything above it), and what the link reaches stays bound read-only, so nothing is written through the link, nor directly; - a dangling link is refused when the directory it stops in is writable (a command could create the target). Measured locally: ~/.ghcup-like -> /var/tmp (root's, world-writable) runs; writes through the link and to /var/tmp directly get "Read-only file system"; /var/tmp can't be moved. Test (fails without): test/project-command-home-hops.test.ts (revised: the dangling skeleton link now points into /, which no one but root can fill), 3 of 3 fail at 47b7702. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 3, past the two-round limit)Delta check of head CI is green, and the round-2 hop bypass is closed. But the pin rule is only half of How I measured
What's inside the sandbox: is every pinned or bound path really read-only?
Outside the sandbox: pre-existing trust?
TOCTOU between the check and the mount
Requested checks
Other checks
Required changes
For the owner (a trade-off, not a required change)The builder chose availability over the brief's strict rule, and said so in the body. The cost is the cross-user residual above. One option would make the strict rule cheap for new installs (inferred): create agent users with an empty skeleton ( Refusals: none. |
… dangling one through a writable directory 7f3dfc8 pinned only the writable directories a home link passed. A root-owned directory below a pinned one was free: with `~/.profile -> W/D/c` (W root's, writable by everyone, not sticky), a command renamed W/D away, made a new W/D and planted c, which the agent user then read through ~/.profile outside the sandbox (S1); the same with a dangling link (S2) (the #899 round-3 review, measured in a user namespace). Now, as add() pins a credential's way: - every directory above an allowed link's target is bound onto itself (`above "$x"`) before the target is bound read-only, so none can be renamed away and recreated; - a dangling link is refused when any directory on its way outside the home is writable to the agent (AW, set when hop() pins one), or the one it stops in is; else the directories above it are pinned. An absolute link target in the home now goes on from the home, its way there pinned once, instead of walking every directory above the home again: 500 absolute inward links in ~/.config cost 3.7 s per command instead of 13.9 s (measured, temp home 10 deep). The comments now name the cross-user residual: the pins and the read-only bind hold in this agent's sandboxes only; another user who can write such a directory, another instance's agent user's sandboxed command among them, still can. Test (fails without): test/project-command-home-pins.test.ts, run in `unshare --user --map-root-user --map-auto --mount` (no sudo), 2 of 2 fail at 7f3dfc8: S1 and S2 both read PLANTED afterwards. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Second adversarial review: CHANGES NEEDED (head
|
| # | Attack (aimed at) | Result |
|---|---|---|
| A | ~/.profile -> W/D/back, with back a link in root's D that points back into the home; rename D and plant (S1 through a hop) |
Held: the symlink branch pins above back, and mv gets Device or resource busy (measured) |
| B | /proc/self/fd/N append on a home file; O_PATH dir fd, then /proc/self/fd/N/.bashrc; openat(O_CREAT) from a home dir fd; /proc/1/root/<home> |
Held: Read-only file system on all four (measured) |
| C | Hard links: home file to the worktree, and worktree file into the home | Held: Invalid cross-device link and Read-only file system (measured) |
| D | user.* xattr on ~/.bashrc |
Held: EROFS (measured) |
| E | Bind propagation: a nested unshare -Urm, then umount -l, remount,bind,rw and --make-shared on the home, then write |
Held: not mounted / permission denied, and the write gets Read-only file system. mountinfo shows the home ro (measured) |
| F | Inherited fds | Inside, only 0 (/dev/null), 1 (pipe) and 2 (the stderr file) (measured, stand-in). In production sudo closes fds above 2 (inferred) |
| G | Plant in ~/.cache and ~/.npm/_npx for later; mv ~/.cache |
Held: written inside, empty outside afterwards; mv gets Read-only file system (measured) |
| H | crontab from inside |
Fails: fopen: Permission denied (measured). No privilege gain in the user namespace (inferred) |
| I | A slow walk, to time out or fail open | Held (inferred): a sandboxed command can't add links to the home or ~/.config, and a link in a writable directory is refused at its first stat, so it can't lengthen a later walk. The walk is capped by LINK_CAP and 40 hops, and every refusal exits 125 before bwrap. Runs here took 40 to 110 ms (measured) |
| J | XDG_RUNTIME_DIR (stand-in: an agent-owned directory outside the home) |
Writable: a systemd/user/x.service was planted and stayed (measured). If the agent's user manager runs, systemd --user loads units from $XDG_RUNTIME_DIR/systemd/user (inferred from systemd.unit(5); reach is a guess). /tmp/claude-<uid> works the same way (inferred). The body names both as a follow-up, but no issue is filed (measured: open-issue search) |
| K | run_as.agent_home differs from the passwd home |
The wrapper protects only the configured path and never compares it with getent passwd (inferred from projectCommand). A misconfiguration, not a route project code opens. Suggested hardening, not required |
| L | A second mount of the root filesystem (A4) | On this WSL host, /mnt/wslg/distro is mounted ro (measured), so it's no route here. Still the named residual elsewhere |
PR's own tests: pins, hops, links and home gave # tests 21, # pass 21, # fail 0, # skipped 0 (measured, with PROJECT_SANDBOX_REQUIRED=1 and a temp HOME and TMPDIR). None of them covers routes 1 to 3 (measured by grep), so they don't catch the most dangerous attack I found. CI at the head: 25 pass, 2 pending (measured).
"What this can never do" and "Compromised component", line by line:
- "Make the agent's home writable": false for route 1's layout.
- "every directory above what it reaches is pinned": false for devices (route 2).
- "Unmask anything" and "home not a directory, or
/": held (not contradicted). - "Touch
merge-policy.ts": true; it isn't in the file list (measured). - harness-config lock: not re-attacked; it's outside this brief's attack goals.
- Compromised component, the project-command row: it doesn't list routes 1 and 2.
Required changes
- Pin the home's way always (
above "$hr/."afterhr=$R,src/os/index.ts:816). Add a test in the pins test's namespace: a writable grandparent, no~/.claude, rename, recreate and plant; the home must still read# orig. - Pin above a device target before skipping its bind (
src/os/index.ts:852), with a test: the device fixture, rename D, and the link still reads the device. - Quote the way in link-mode
unres(${cur@Q},src/os/index.ts:755), and fix its wording, which blames the agent for root's unsearchable directory. Add a test with a newline in a directory name. - Correct the claims (principle 5):
changelog.d/899-agent-home-read-only.md:17and:41,docs/auth.md:58and the body's "every directory above … is pinned". Correct "What this can never do" too, unless 1 and 2 land. - File the follow-up issue the body promises: agent-owned paths outside the home (
$XDG_RUNTIME_DIR, includingsystemd --userruntime units, and/tmp/claude-<uid>) and live agent-user services. Round 1 flagged it too, and it isn't filed.
Each fix is a one-line change in the class the PR already closes, so none needs an owner decision. K is optional hardening.
Delta final check, round 11: CHANGES NEEDED (PR body only; code, docs and changelog at
|
| # | Finding | Result at cc7e84db |
Label |
|---|---|---|---|
| A | After install find skipped ~/.cache and ~/.npm |
The find is now find <agent home> -xdev -type f -links +1. It lists /mnt/ch/agent/.cache/tool/run.sh, and the write through work/keep still lands (# orig PLANTED). That route is now named in the changelog and in docs/auth.md, together with uv's cache. Fixed |
measured |
| B | Hard link to a file an allowed link reaches | The route is still open, as expected: .g/env and T/env give Read-only file system, and work/pre2 gives wrote. It is now a separate bullet in docs/auth.md. The changelog and the body name it as a highlight. Both After install and docs/auth.md say the check doesn't find it, and that holds: the check lists nothing for this case. Fixed |
measured |
| C | The A2b bullet's "from a directory the agent user can write" | The bullet now says "from wherever that name is". The probe matches it: rodir isn't writable (no), yet the write through rodir/b lands in ~/.bashrc. Fixed |
measured |
| D | "A write anywhere else in the home fails" | The changelog's Upgrading section, docs/auth.md and the body's Intent line all now name the masked ~/.claude* directories and the directories credentials.yaml hides as writable throwaway tmpfs mounts. The probe matches: ~/.claude/x gives wrote and the host's .claude stays empty, while ~/.local/x gives Read-only file system. Fixed |
measured |
| E | The body's Still open list | Still open now says it is highlights only and points to docs/auth.md. It includes links inside what an allowed link reaches. Fixed |
measured (by reading) |
| F | macOS home not at its canonical path | seatbeltProfileFor (src/os/index.ts:954) refuses a home whose realpathSync differs from the configured path, and also a home that can't be resolved. It never rewrites the path. projectCommand's darwin branch (:1007) now calls it. Fixed. The "fail-closed" section below has the details |
inferred (macOS isn't in CI) |
| G | "A command can't make a new one" | The text now says this holds on Linux and is untested on macOS. A new probe, mkln, ran ln ~/.bashrc into ~/.cache and into the working directory, both inside the sandbox. Both failed with Invalid cross-device link, and the host's .bashrc link count stayed at 1. Fixed |
measured (Linux) |
Control (measured): echo >> ~/.bashrc still gives Read-only file system, and .bashrc is unchanged.
F: does it fail closed, and can it refuse a canonical home or reach Linux?
- Fail-closed (inferred, from the code): both refusals throw inside
projectCommandbefore it returnsfileandargs. All six callers (worktrees.ts:132,157,queue.ts:84,tune.ts:47,browser/run.ts:200,coordinator/shared.ts:214) spawn only from that return value, so a refused home never runs. This is the same path the existingnormalHomerefusal takes. - A canonical home (measured): test 4 builds the profile for a canonical temp home. The result is byte-identical to
seatbeltProfile(home, [])and contains(deny file-write* (subpath "<home>")). A trailing slash or..is already refused bynormalHome(:984), so the comparison sees the normal form only (inferred). - Linux (inferred, from the diff):
seatbeltProfileForis called only insideif (process.platform === 'darwin'). The Linux wrapper path is unchanged, and the probes above ran the Linux wrapper unchanged.
Single list
docs/auth.md's "What the read-only home does not stop" calls itself the one full list (measured).- The changelog's Upgrading and Guardrail sections, and the body's Intent, Still open, What remains and Compromised component, all point to it and say "among them" or "highlights only" (measured, by grep and reading).
- Grepping
docs/andREADME.mdfor completeness wording finds no other claim about this list (measured). - Exception: the PR body still has stale completeness claims. See required change 2.
Fail-without, gates and checks
- Fail-without (measured): I wrote
test/project-command-seatbelt-home.test.tsinto a fresh worktree at3ec041b6. Its sha256 is87aaaae3bbcd…2e08, identical to the builder's. I built it withnpx tscand rannode --import ./dist/test/isolate.js --test dist/test/project-command-seatbelt-home.test.js. The result was# tests 4,# pass 0,# fail 4, each failing withno seatbeltProfileFor: the macOS profile is built for any configured home. That failure means the export is missing. It can't be avoided, because the fix is a new function. - At the head (measured): I ran
project-command-seatbelt-home,docs-guardrails,changelogandchangelog-fragments-selfcheck. The result was# tests 42,# pass 42,# fail 0.- The test has no PATH and spawns nothing, so it is pure.
- I didn't run
changelog-fragments.test.tslocally because it spawnsrelease.mjswith my PATH. CI ran it. - I didn't run the Linux
project-command-home*tests locally. The delta doesn't change Linux code, and CI shards ran them.
- Weakening gate (measured): the delta changes or removes no existing test line. Its only test change is one new file. The code change only adds a refusal.
src/merge-policy-weakening.tsis untouched. - Typecheck (measured):
npm run typecheckis clean. - Denylist (measured):
denylist: 1420 files clean. - Size (measured):
npm pack --dry-run --jsongivessize1,487,816 andunpackedSize4,983,100. That is +401 / +1,307 over3ec041b6and under the budget of 2,050,000 / 6,400,000 (test/package-files.test.ts:115). It matches the body's numbers.
Merge-tree (measured, git merge-tree --write-tree)
- origin/main (
d2a549f4): clean, and it is already an ancestor of the head. - Clean: Adversarial suite: attack cases for doubt, the off switches, the stop, weakening, evals and outside-plan (#707) [PROTECTED: owner merges] #957, Bench: bench pull pulls each task's image by pinned digest and puts its base tree, from the image, in the held-out store (#748 slice 4) [PROTECTED: owner merges] #947, git: an instance's git runs nothing its checkout's config names, and .git/ is the coordinator's (#926) [PROTECTED: owner merges] #944, Adversarial suite: attack cases for the agent guards, push guard, push gate, commit scan and redaction (#707) [PROTECTED: owner merges] #932, push limits: read each changed name as itself (-z); lock the rename and odd-name tests (#724) [PROTECTED: owner merges] #928, git: one runner for every engine git call, with replace refs and grafts off (#807) [PROTECTED: owner merges] #927, Redaction: one shared list of token shapes, prefixed dashboard keys, no own redactors (#889) [PROTECTED: owner merges] #921, Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #919, Receipts: signed, chained merge receipts anyone can verify offline (#717 slice 1) [PROTECTED: owner merges] #918, Review levels: a path that looks like a locked or protected one (UTS #39 skeleton) always waits (#763) [PROTECTED: owner merges] #910, Merge: hold a PR whose own tests run none of the app code it changes [PROTECTED: owner merges] #903, Setup: the machine helper's sudoers rule follows the engine, rewritten on its own only when it narrows; --replace-sudoers for the rest (#898) [PROTECTED: owner merges] #902, Sandbox: a task's hidden paths and the project's secret paths are denied by the OS sandbox and Read rules, not only the hook (#890) [PROTECTED: owner merges] #893 and Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746.
- Conflicts, all present before this round:
- review: the repo's merge process in docs/process.md, locked, outside the decisions index [PROTECTED: owner merges] #920
2647eeaf:src/review.ts - Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886
dac9dcd8:src/review.ts - setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904
1cc9abb9:scripts/setup/credentials.sh - Each conflict is identical against
3ec041b6, and round 11 doesn't touch those files. setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904 still auto-mergesdocs/auth.mdandsrc/os/index.ts.
- review: the repo's merge process in docs/process.md, locked, outside the decisions index [PROTECTED: owner merges] #920
CI at cc7e84db (measured, gh pr checks; nothing rerun)
Both runs (ci 39557635072 and ci slow 39557634685) are for cc7e84db and still in_progress.
- Required checks, all pass: agentshield, dco, denylist, new-deps, push-gate, scan (×2) and test (ubuntu-latest, 24).
- Other checks that pass:
- every test shard on node 24;
- shards 2/4, 3/4 and 4/4 on node 22;
- bootstrap on macOS, Ubuntu and windows-wsl;
- desktop, scope, and the Claude sign-in probe.
- Still pending:
- test shard 1/4 (ubuntu-latest, 22);
- add-project (real systemd);
- egress firewall (real systemd);
- test-run sandbox (real systemd);
- wizard end to end (real systemd).
Required changes (PR body only, no new head needed)
-
Adversarial pass for round 11. Round 11 adds code (
seatbeltProfileFor), but neither the Adversarial pass table nor the Round 11 section records an attack against it. Add the attacks you tried and their results, with labels. Examples:- a symlinked home;
/var→/private/var;- a missing home;
- a home with a trailing slash or a differently-cased path;
- whether any caller catches the throw.
(measured, by grep: the body contains no
seatbeltProfileFor,realpathorcanonicalin the Adversarial pass section.) -
Stale completeness claims in the body.
- Round 10 says "the Guardrail entry's 'Four things fall outside that' is now the full list" and "the text now matches it in full". Round 9 says the list "is corrected and completed". Mark these as superseded by round 11, for example "(was; since round 11 the one full list is docs/auth.md)".
- "What this can never do", first bullet: "The ways around it that remain are listed under 'Still open' below, and in the changelog's Guardrail changes". Point this at
docs/auth.mdinstead.
(measured, by reading body lines 142, 145, 153 and 416.)
Not required (for the owner's information)
- The darwin wiring isn't tested (inferred).
- No test drives
projectCommand's darwin branch. If:1007went back toseatbeltProfile(...), no test would fail, and greppingtest/finds nosandbox-exec. - A test that sets
process.platformtodarwinand asserts the refusal for a symlinked home would cover it. - The round-10 review asked only for a test on the profile builder, so this is a suggestion.
- No test drives
- Case-insensitive paths on macOS (guess).
realpathSync(the JS implementation) resolves symlinks but keeps the case it was given. On case-insensitive APFS, a configured/Users/Agentfor an on-disk/Users/agentwould pass the check. Whether seatbelt then fails to match depends on how the kernel names the vnode, and I can't test that here.realpathSync.native(libcrealpath(3), which returns the on-disk case on Darwin) would close it, if that is true.- Neither part of this is measured.
…tCommand's macOS branch itself (#798) Round 12 of #899 (the round-11 delta check): - seatbeltProfileFor resolves the home with realpathSync.native. Node's JS realpath keeps the case it was given, so on a case-insensitive disk a home configured with the wrong case could pass the canonical-path check while the profile matched no real path (a guess about reach; macOS isn't in CI). - projectCommand takes `platform` and `realpath` for tests, as it takes `sandboxTool`, so its macOS branch is driven on any platform: had that branch gone back to seatbeltProfile, no test failed before. Test (fails without): test/project-command-seatbelt-darwin.test.ts (a symlinked, a missing and a wrong-case home refused by projectCommand on macOS; a canonical home run under sandbox-exec with the profile; the default resolver is realpathSync.native): 5 fail at cc7e84d. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Delta final check, round 12: CHANGES NEEDED (merge the new main; the head's own code passes)Head checked: Scope (measured, Round-11 findings (issuecomment-6310053568) and the coordinator's additions
Checks
Merge-tree (measured,
|
Second adversarial review, round 12: CHANGES NEEDED (one macOS route the one full list doesn't name)Head checked: Scope (measured): Attacks
The read-only home held against every Linux attack (1 to 9). The test seams can't be reached from config, the environment, a task file or the agent (11). The macOS canonical-path refusal works as built (12). Its own tests already cover symlink, case, missing and Required change
Minor (text; not blocking alone)
None of this is a regression: on main, macOS protected nothing in the home (inferred from the diff). There's no new trade-off for the owner: H is the fail-closed default. |
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… and their cost (#798) Round 13 of #899: - H (the round-12 second review): the macOS profile had no rule above the home, so with a home under a directory the agent owns a command could rename that directory away and put one holding a planted .bashrc in its place (the A24 shape closed on Linux). seatbeltProfile now denies writes to each directory above the home itself (`literal`), the macOS form of the wrapper's `above "$hr/."`. Untested on a real macOS; docs/auth.md says so. - #919 (merged from main) made a deploy's trigger and verify project commands. docs/auth.md lists them, and the changelog's Upgrading section names the cost: one that writes the agent's home (~/.kube/cache, ~/.config/..., ~/.docker) now gets "Read-only file system" (measured with a stand-in), by the owner's decision to keep the whole home read-only (#912). - docs/auth.md: "any IPC channel" outside the home (the IPC namespace is shared), and a writable block device named next to a second mount; only a character device plants nothing. Test (fails without): test/project-command-seatbelt-above.test.ts (the profile pins /Volumes/x/wl, /Volumes/x and /Volumes for a home at /Volumes/x/wl/agent): 1 of 2 fails at 6887352; the control (a home directly under /) passes. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Delta final check, round 13: PASS (CI at the head still running)Head checked: Scope (measured, 1. The merge commit (measured)
2. #919's deploy sandbox on the merged tree (measured)Each run used explicit files and
A refusal is a failed deploy, never an unsandboxed run.
The cost (measured). I copied the builder's
This cost is named in 3. macOS: the directories above the home (finding H)The profile text (measured). I adapted darwin12.mjs's cases to this head (
Writes the command still needs. Take a working directory above the home, Why every directory above is covered (inferred). Untested on a real macOS: the code comment, Fail-without (measured).
Linux is unchanged by round 13 (measured). I ran round 12's 4. The single full list (measured, by grep and reading)
5. Other checks
Merge-tree (measured,
|
Second adversarial review, round 13: head
|
| # | Attack (claim aimed at) | Result | Label |
|---|---|---|---|
| L1 | A deploy refusal turned into an unsandboxed retry, or a weaker profile (goal 2) | Holds. deployStep (src/coordinator/landing.ts:432-441) maps a throw or a 125 "project command not run" to refused, and deploy ends on it. sh (src/coordinator/shared.ts:212-214) builds the argv with projectCommand and the same this.d.commandsAs as every check (src/coordinator/base.ts:259-261). No try falls back. deploy-sandbox + project-command-seatbelt-{above,darwin,home} + project-command-home, real bwrap, temp HOME/TMPDIR, PROJECT_SANDBOX_REQUIRED=1: # tests 25, # pass 25, # fail 0, # skipped 0 |
measured (tests), inferred (code) |
| L2 | A deploy or check writes the home directly | Holds. Through this head's wrapper: /mnt/b10/agent/.bashrc: Read-only file system, and .bashrc was unchanged afterwards. The Linux wrapper is byte-identical to round 12's: git diff -U0 68873520 c63db3b3 -- src/os/index.ts has one hunk, in seatbeltProfile. So round 12's submount, nested-namespace, fd and cwd results still apply |
measured |
| L3 | The list is complete (goal 1, deploy path). A project command writes .claude/settings.json with a SessionStart hook into its working directory. The next agent run there executes the hook outside any sandbox, and the hook writes the home |
Route, not named in docs/auth.md. In a coordinator-owned 2775 directory that the agent's group can write (the default checkout layout: src/service.ts:231, src/dep-cache.ts:7-8), the sandboxed command wrote the file, owned 1000:1000, and it persisted. projectSettingsRefusal returns null for it (src/agent-settings.ts:164). cliArgs passes --setting-sources project (src/runner.ts:458). The same hook command, run as the agent user outside the sandbox (the stand-in for claude's hook runner), appended to ~/.bashrc. New with #919: a deploy's trigger and verify run in the main checkout (this.d.repo, landing.ts:434), and a corrections run starts there too (src/coordinator/corrections.ts:125, as does the stage judge at src/coordinator.ts:343). #958 is filed, but its fix compares "the worktree's" settings only |
measured (write, persistence, refusal null, setting sources, stand-in hook); inferred (Claude Code runs project hooks unsandboxed; corrections runs as the agent user; the main checkout is group-writable) |
| L4 | The list is complete. A project command (a deploy in the main checkout, a check in a worktree) writes the checkout's .git/config, and the coordinator's git runs what it names (#926: filter, gpg.program, include) as the coordinator user, which runs commands as the agent user through sudo |
Route, not named in docs/auth.md. instanceEnv neutralises only core.hooksPath, core.fsmonitor and credential.helper (src/service.ts:136-150). Its comment says "checkouts agents can write to" |
inferred (not run) |
| L5 | The IPC bullet's cause: "since the sandbox shares the IPC namespace" | False as the cause. A stand-in agent-user listener on an abstract socket, outside the sandbox: the sandboxed command connected. A child in a new IPC namespace connected too. A child in a new network namespace got Connection refused. The sandbox's ns/net inode equals the outside one (net:[4026532301] both). Abstract sockets, and loopback ports, are scoped by the network namespace, so --unshare-ipc would not close the measured route |
measured |
| L6 | Setuid or setgid helpers (crontab, at, fusermount) used to act outside the sandbox | Holds: NoNewPrivs: 1 inside |
measured (flag); inferred (per tool) |
| L7 | TIOCSTI into a controlling terminal | Holds here. sh spawns detached (spawnDetached, src/os/index.ts:628), and dev.tty.legacy_tiocsti is 0 on this host. Out of scope, not a finding: worktree setup steps spawn without detached (src/worktrees.ts:135,158), and the bwrap args have no --new-session. On a kernel before 6.2, with the coordinator in a terminal, that is the old bwrap TIOCSTI class |
measured (sysctl); guess (reach) |
| L8 | macOS profile text (goal 3): the H shape (/Users/Shared/wl/agent), a volume-root home (/Volumes/agentdisk), a home directly under /, SBPL-significant characters () (allow file-write* #, ;), a /System/Volumes/Data/... name |
Holds on text. A literal deny for each directory above the home, and none above a home directly under / (nothing to pin there). Parens, # and ; stay inert inside string literals, and the regex escapes them. Whether seatbelt matches a firmlinked data-volume name against /Users/... is untested |
measured (text, injected resolver); guess (firmlinks) |
| L9 | macOS: a mount over the home, a directory in it, or the agent-owned directory above it (the H shape) | Not named. The profile has no file-mount/file-unmount rule (measured on all six profiles above), and macOS has no mount namespace, so a mount outlives the command. Whether the agent user can mount there from inside sandbox-exec (mount_smbfs/mount_webdav onto a directory it owns, or hdiutil attach -mountpoint through diskarbitrationd) is untested. The macOS bullet's "may still be replaceable" covers the H shape in effect, but not a mount over the home itself |
measured (profile text); guess (route) |
| L10 | macOS out-of-process actors: launchctl submit (launchd runs a job as the agent user, unsandboxed), crontab (setuid; sandbox-exec sets no NNP) |
Covered in spirit by the IPC bullet ("systemd --user … and the like"), whose stated cause doesn't apply on macOS |
guess |
| L11 | A mode with no sandbox at all | allow_same_user (admin-only, src/instance.ts:348) leaves runAs null, and projectCommand then returns plain bash (src/os/index.ts:986), deploy included. Pre-existing and an explicit loosening, not a finding. Optional: one line in the list |
inferred |
| L12 | Completeness sentences outside docs/auth.md (goal 4) |
The changelog's "docs/auth.md lists them all" and the body's Intent "lists them all" and Compromised component "listed in full … agent-owned paths outside the home (#933)" are false while L3/L4 are missing. The docs list has #933 only as a parenthetical. Known nit, still present: "sockets outside the home" in What remains and Compromised component |
measured (grep: docs/auth.md cites #933 ×2 and #954, never #958 or #926) |
The PR's own tests catch its own code routes (25/25 above). L3 to L5 are text-list findings, not wrapper bugs, so no new test is expected for them.
Required changes (text; principle 5)
docs/auth.md, the list: add one bullet for files outside the home that a later unsandboxed process reads and a project command can write.- The checkout's
.claude/settings.jsonhooks, in worktrees and in the main checkout, where a deploy's trigger and verify run and corrections and stage-judge runs start (Worktree .claude/settings.json hooks written by a project command run unsandboxed at the next agent run #958). - The checkout's
.git/config, which the coordinator's git acts on (Security: agent-writable .git lets an agent plant config (filter, gpg.program, include) that the coordinator's git runs unsandboxed #926). - Agent-owned paths such as
$XDG_RUNTIME_DIRand/tmp/claude-<uid>(Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933).
- The checkout's
docs/auth.md, the IPC bullet: the cause is that the sandbox shares the network namespace (abstract sockets, loopback ports) as well as the IPC namespace and the filesystem ($XDG_RUNTIME_DIR). Measured above.docs/auth.md, the macOS bullet, and line 58's "On macOS nothing in the home is writable": say the whole profile is untested on a real macOS. Name mounts: there is nofile-mountrule, and there is no mount namespace.- Body: "sockets outside the home" becomes "any IPC channel outside the home" (What remains, Compromised component). Fix the "listed in full" lines once item 1 lands.
Not for this PR (orchestrator): extend #958 to name the main checkout (the deploy's cwd; corrections and stage-judge runs), so its fix doesn't cover worktrees only. A (deny file-mount)-style rule for macOS needs measuring on a real Mac first: a profile op this macOS doesn't know would break every project command there.
Not found: any Linux route through the wrapper, the deploy trigger or the verify path that defeats the read-only home directly. Any deploy refusal that becomes an unsandboxed or weaker run. Any profile text that a home shape can escape.
…space, and macOS as untested (#798) The round-13 second review of #899 found the code held, and docs/auth.md's one full list missing filed routes: - files outside the home that a later process outside the sandbox reads and a project command can write (the checkout's .claude/settings.json hooks, its .git/config, agent-owned paths; #958, #926, #933); - the IPC bullet's cause: the sandbox shares the network namespace (abstract sockets, loopback ports) as well as the IPC namespace and the filesystem, so unsharing IPC alone wouldn't close it (measured by the review); - macOS: the whole profile is untested on a real Mac, and mounts are unknown (no rule about them); - allow_same_user (admin-only, off by default): project commands, a deploy's included, run unsandboxed. The changelog's highlights follow. Test (fails without): none; text only. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (body only): delta final check, round 14, head
|
| Item | Result | Label |
|---|---|---|
docs/auth.md full list: the checkout's .claude/settings.json hooks, in a worktree and in the main checkout (#958) |
Present. New bullet: "files outside the home that a later process outside the sandbox reads, and that a project command can write …" | measured (diff) |
… the checkout's .git/config (#926) |
Present, same bullet | measured (diff) |
| … agent-owned paths (#933) | Present, same bullet ($XDG_RUNTIME_DIR, /tmp/claude-<uid>) |
measured (diff) |
| IPC bullet: the cause is the shared network namespace | Present: "shares the network namespace (abstract sockets, loopback ports), the IPC namespace and the filesystem … Unsharing the IPC namespace alone wouldn't close this". Re-measured with round 13's L5 probe, using this head's wrapper argv (projectCommand from this head's dist) in unshare --user --map-root-user --map-auto --mount --net on a private tmpfs. Outside the sandbox: net:[4026532303]; inside it: net:[4026532303], the same namespace. The sandboxed command reached the stand-in agent-user listener's abstract socket (connected sandbox). A child in a new IPC namespace also connected (connected new-ipc-ns). A child in a new network namespace got refused new-net-ns Connection refused. The stated cause holds |
measured |
| macOS: untested as a whole, mounts unknown | Present twice: the macOS sentence on line 58 ("meant to leave … untested on a real Mac, and mounts are unknown") and the list's macOS bullet ("the whole profile is untested … no rule about mounts") | measured (diff) |
Optional allow_same_user line |
Present and true. allow_same_user defaults to false (src/instance.ts:188). With no run_as, projectCommand returns plain bash (src/os/index.ts:984) |
measured (code read); inferred (that the deploy goes through the same projectCommand, per round 13's L1) |
| Body wording ("sockets outside the home" becomes "any IPC channel outside the home", in What remains and in Compromised component) | Partly done. Compromised component now says "any IPC channel outside the home". What remains still has a bullet headed "Sockets outside the home" that says "reachable through any socket outside the home". See required change 1 | measured (grep of the body at a5277e54) |
3. One completeness claim (measured)
In the docs and changelog, only docs/auth.md claims a full list ("This is the one full list"). The changelog's two pointers ("docs/auth.md lists them all", "listed in full in docs/auth.md") and the body's Intent, What this can never do and Compromised component all point to it. A grep of docs/, changelog.d/, README.md and src/os/index.ts finds no other completeness claim about this list.
4. #707 (inferred, from reading the new text)
The new text names files and namespaces in general terms. It has no steps, commands or payloads, and neither does the body's Round 14 section.
5. Tests, denylist, CI
- Built at the head.
docs-guardrails,changelog,changelog-fragments,changelog-fragments-selfcheck,brandanddenylistran with a temp HOME, TMPDIR and state, and recording stand-ins first on PATH:# tests 54,# pass 54,# fail 0. No host tool was called (no recorder log). (measured) npm run check:denylist:denylist: 1428 files clean. (measured)- CI at
a5277e54: every required check passed (agentshield,dco,denylist,new-deps,push-gate,scan,test (ubuntu-latest, 22),test (ubuntu-latest, 24)). So did every other check: all 8 shards,desktop,scope, the bootstraps (ubuntu, macos, windows-wsl), the sign-in probe, and the real-systemd jobs (add-project, egress firewall, test-run sandbox, wizard). None is pending, and none was rerun. (measured)
Merge-tree (read-only git merge-tree --write-tree)
- origin/main
9f9533aais an ancestor of the head, so the merge is clean. (measured) - Clean against the open heads tool guard: protected-path checks decide on the file a write really reaches; redact secrets in keys [PROTECTED: owner merges] #976, Adversarial suite: attack cases for doubt, the off switches, the stop, weakening, evals and outside-plan (#707) [PROTECTED: owner merges] #957, Bench: bench pull pulls each task's image by pinned digest and puts its base tree, from the image, in the held-out store (#748 slice 4) [PROTECTED: owner merges] #947, Adversarial suite: attack cases for the agent guards, push guard, push gate, commit scan and redaction (#707) [PROTECTED: owner merges] #932, git: one runner for every engine git call, with replace refs and grafts off (#807) [PROTECTED: owner merges] #927, Redaction: one shared list of token shapes, prefixed dashboard keys, no own redactors (#889) [PROTECTED: owner merges] #921, Receipts: signed, chained merge receipts anyone can verify offline (#717 slice 1) [PROTECTED: owner merges] #918, Review levels: a path that looks like a locked or protected one (UTS #39 skeleton) always waits (#763) [PROTECTED: owner merges] #910, Setup: the machine helper's sudoers rule follows the engine, rewritten on its own only when it narrows; --replace-sudoers for the rest (#898) [PROTECTED: owner merges] #902, Sandbox: a task's hidden paths and the project's secret paths are denied by the OS sandbox and Read rules, not only the hook (#890) [PROTECTED: owner merges] #893, Pin this repo's required checks to the workflow files that post them (after #788 and the engine upgrade) [PROTECTED: owner merges] #794 and Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746. (measured)
- Conflicts with DO NOT MERGE: combined CI for batch 5 (#904, #886) #977 (draft combined queue:
scripts/setup/credentials.sh), git: an instance's git runs nothing its checkout's config names, and .git/ is the coordinator's (#926) [PROTECTED: owner merges] #944 (src/push-check.ts), review: the repo's merge process in docs/process.md, locked, outside the decisions index [PROTECTED: owner merges] #920 (src/review.ts), setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904 (scripts/setup/credentials.sh), Merge: hold a PR whose own tests run none of the app code it changes [PROTECTED: owner merges] #903 (src/coordinator/base.ts) and Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886 (src/review.ts). None of those files is in this round's delta, anddocs/auth.mdauto-merges with DO NOT MERGE: combined CI for batch 5 (#904, #886) #977 and setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904. So the conflicts are the same as atc63db3b3, not new. (measured: the conflicts; inferred: that they're unchanged)
Required changes (PR body only; principle 5)
- What remains: the "Sockets outside the home" bullet still says "reachable through any socket outside the home". Make it "any IPC channel outside the home", and name the shared network namespace as the cause, as
docs/auth.mddoes. Round 13's item 4 named this section explicitly. - What this can never do, Still open: "any IPC channel outside it (the IPC namespace is shared)" still gives the old cause. Make it "(the sandbox shares the network and IPC namespaces)", which is the changelog's wording and the measured cause.
Neither change touches the head. After these two body edits, I found nothing else to change at a5277e54.
Main at 7b0a96a (#904 and #886 merged). Conflicts resolved: - src/review.ts: #886's `// check-table: never-shrink` mark kept on ALWAYS_L3, with this PR's [SELF_MODIFICATION, HARNESS_CONFIG]; this PR's new HARNESS_CONFIG_GLOBS table gets its own never-shrink mark, as #886 moved registrations to in-file marks. - scripts/setup/credentials.sh: #904's side. Its API-key check (claude_api_key_login_ok, run_as_bounded) runs as the agent user with `sudo -H -u`, never a login shell, which is what this PR's change there did; so this PR no longer changes the file, and the changelog fragment drops its credentials.sh line. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Second review (verification-scoped)Verdict: PASS on
I built no new payloads. Every documented route that was closed still holds. Every route that still works is in Before merge (required step for the builder, not a finding on this head): origin/main has moved to
The merged tree then needs a delta check and CI. Setup (measured)
Attacks re-run
Fail-without (measured)Each test was written byte-identically as a new file
The home, links, hops, pins and way files changed after the round where their fail-without was first measured (their sha256 differs from the body's). These runs use the current files, and each still fails at its base. Claims against the code (inferred from reading; measured where a probe above covers it)The home is read-only, with throwaway caches.
The home's way is always pinned; a home that isn't a directory, or is The seven refusal cases, and the others (each at the line named):
An allowed link: every directory above its target is pinned and the target is bound RO ( Residuals in
macOS:
Statements of completeness: the changelog says "lists them all" and the body says "listed in full". Each points to the Optional (not blocking)
|
Second review (verification-scoped)Verdict: PASS on The merge (measured)
Tests (measured)Run with temp HOME, TMPDIR and state,
Result: Probes re-run (measured)These were run as written against this build, all on the private
Nothing was left in Required changes: none. |
Evaluator verdict: CHANGES NEEDED (round 15 delta, head
|
Evaluator verdict: PASS (round 15 delta follow-up, head
|
main brought #947 and #899. One conflict, in src/review.ts: the HARNESS_CONFIG block from #899 (its JSDoc, HARNESS_CONFIG and the never-shrink HARNESS_CONFIG_GLOBS) and this PR's decision-record block (DECISION_FILES, DECISION_DIRS, DECISION_RECORD_GLOBS and onePartPerGlob) were both added after hidesSubmodules. Both are kept whole: this PR's block first, then the one from #899, then SELF_MODIFICATION as before. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
One conflict, test/review.test.ts's GUARD_TREE list: the union of both sides. This branch adds src/os/git.ts; Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> #976 adds src/guardrails/reach.ts and src/os/process-links.ts. No entry from either side is dropped.
Intent: a project command (it runs agent-written code as the agent user, in bubblewrap since #713) can no longer plant config in the agent user's home (
~/.bashrc,~/.profile,~/.gitconfig,~/.npmrc,~/.ssh/config,~/.local/bin/...) for a later agent-user process outside the sandbox to read and act on. The home is now read-only inside the sandbox. On Linux, the only places in it a command can write are~/.cache,~/.npm, the masked~/.claude*directories and any directorycredentials.yamlhides under the home: each is an empty tmpfs that is thrown away when the command ends. Some ways around the read-only home remain;docs/auth.md, "What the read-only home does not stop", lists them all. A repo'sreview.yamlalso can no longer lowerharness-config, wheretests.yamllives: it is locked in code like self-modification.Risk class: protected. It changes
src/os/index.ts(the project-command sandbox),src/review.ts(review levels; a guard file),scripts/ci/**,.github/workflows/ci-slow.ymland (round 5).github/workflows/ci.yml. The owner merges.Closes #798
The choice: a read-only home inside the sandbox, not a clean HOME outside it
There were two ways to close the route:
sudo -u <agent> -ilogin shell, cron, and the agents' ownclaude. Claude Code sources~/.bashrcfor its shell snapshot (inferred from Claude Code's documented shell-snapshot behaviour; not measured here).GIT_CONFIG_GLOBAL=/dev/null,--norc, ...). This only covers the processes the engine starts. It also can't cover the agents' ownclaude, whose login is in~/.claudeunder the real HOME (inferred).I chose (A) because it closes the class. (B) would leave every reader we don't control open.
How (A) works (
LINUX_SANDBOXinsrc/os/index.ts):--ro-bind <home> <home>.EROFSwhere you used to getEBUSY.~/.cacheand~/.npmeach get a--tmpfsthat starts empty and is gone when the command ends.~/.cacheholds code that unsandboxed processes run (corepack, go-build, Playwright browsers), and~/.npm/_npxholds code thatnpxruns (inferred). A writable persistent cache would just be another place to plant./, is refused before anything runs. With no home to make read-only, a command could create one and plant config in it.above "$hr/."), with or without a~/.claude*mask. Before round 5 it was pinned only by accident (above a~/.claude*mask, or a link into the home), so a home atT/a/homewithathe agent's, T writable and no~/.claude*let a command renameT/aaway, recreate the home's path and plant.bashrc(route 1 of the second adversarial review, measured).~/.config, is walked from the home. The walk goes one component and one link at a time, withwalk()'s own rule from the credential paths, in "link mode" (hop).7f3dfc8b). The refusal names the link, the hop that makes it unsafe, and thesudo -u <agent> rm --that removes it, with every link listed at once. The cases:walk()'s own credential rule. So a link that resolves back into the home through an agent-writable hop fails:~/.profile -> /x/hop/profilewith/x/hop -> ~/dotfiles. Round 2'sreadlink -mcheck missed this (the review measured it).26a39041). A command could create its target, or rename a directory below the writable one away and recreate it (S2)./, or a directory holding the command's working directory, because binding it read-only would take the command's own directory too.26a39041, every directory above the link's final target is pinned too, asadd()pins a credential's way (above "$x"). The same holds above an allowed dangling link's stop point.continue) beforeabove "$x", so~/.x -> W/D/n(n a device) let a command rename D away and write n as a file (route 2 of the second adversarial review, measured). Since round 5 the directories above a device are pinned first, except under/dev: binding/devonto itself costs the sandbox its devices (measured:bwrap --dev-bind / / --bind /dev /devgives/devnodev, andecho x > /dev/nullgetsPermission denied), so the reviewer's literal fix would have broken every~/.bash_history -> /dev/null./dev's writable directories (/dev/shm,/dev/mqueue) are sticky (inferred: the kernel's and systemd's defaults). Round 6: the code no longer rests on that. A device in or below a directory under/devthe agent can write is refused, and so is any way through such a directory, and a device in a writable directory anywhere (A29, A31).walk()pins a credential's way", was false. Only the writable directories and their parents were pinned, not the root-owned directories below a writable one on the way. So~/.profile -> W/D/c(W writable, D root's) let a command rename D away, recreate it and plantc(S1, measured by the reviewer and in the new tests).~/.ghcup -> /usr/local/.ghcup. On the runner,/usr/local/.ghcupis writable by the probe's agent user (CI, measured:decb681a's refusal named it as "owns or can write"). Pinning instead of refusing is in7f3dfc8b.lbinds), so nothing under it is written there, through the link or directly (measured):~/.rt -> /run/user: a write to~/.rt/<uid>/xgotRead-only file system;~/.ghcup-like -> /var/tmp(root's, world-writable): writes through the link and directly to/var/tmpgotRead-only file system, andmv /var/tmpfailed;26a39041in a user namespace: S1 runs and~/.profilestill reads# root; S2 is refused (125); S3, S4 and S5 hold, as before.G/env, and agent A then read it through~/.ghcup/env.~/.bash_history -> /dev/null(not bound, since writing it plants nothing; the directories above it pinned outside/dev, round 5; refused in a directory the agent can write, and under/devthrough a symlink, a..or a writable directory, round 6), and a link that stays inside the home. Refused since round 6: a way into/procor/sys, and anything under/devbut a device.~/.ghcup(its target is world-writable) and keeps CI red. So I followed the other half of the brief, "the same rulewalk()already applies to credential paths":walk()refuses a link in a writable directory and pins a writable directory, andadd()pins every directory above the final path.LINK_CAP(500) symlinks in one of those directories, the run is refused rather than run with some unchecked.~/.config):26a39041, against 13.9 s at7f3dfc8b(the reviewer measured 11.9 s). An absolute target in the home now goes on from the home, rather than walking every directory above it again;statandreadlinkforks. I found no cheap batching that keeps onelstatper decision (Project commands run as the agent user only in a sandbox that hides its credentials; their output is redacted (#713) [PROTECTED: owner merges] #787's invariant), so this cost stays. A home with a handful of links, a skeleton's, costs a few milliseconds (inferred).hopdoes nothing outside link mode (inferred from the code; the isolation tests pass, measured).seatbeltProfileadds(deny file-write* (subpath "<home>"))and refuses a home of/. Since round 11,seatbeltProfileFor(whatprojectCommanduses on darwin) also refuses a home that isn't its own canonical path, or can't be resolved: seatbelt matches canonical paths, so a/var/...home (/private/var/...there) would have matched no rule. There's no throwaway layer there, so nothing in the home is writable.Issue items:
credentials.shreally ransudo -iu <agent> claude auth statusas root. (Round 15: setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904, now merged, replaced that line with its own bounded check,claude_api_key_login_okthroughrun_as_bounded,sudo -H -uand never a login shell; this PR took setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904's side in the merge and no longer changescredentials.sh. What follows is how this PR had changed it.) It now runssudo -H -u <agent> claude auth status --jsonfrom/, asclaude-account.shalready does. The displayed sign-in command (sudo -u <agent> -i claude auth login) is still unchanged. It appears insrc/claude-accounts-retired.tsandscripts/setup/claude-account.sh(Lane D),src/setup-steps.ts(Lane B),web/src/pages/Setup.tsx, docs and about ten test assertions. Changing it means editing other lanes' files, so it's a follow-up. With the home read-only, a sandboxed command can't plant what that login shell would source, so this text is defense in depth only (inferred).LOCKED_CATEGORIESandALWAYS_L3, so it joins the merge policy's floor throughLOCKED_CATEGORIESwith no change tomerge-policy.ts.What breaks (measured where marked; the issue asked to measure it)
Deploy commands (since Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #919 merged, round 13). A deploy's trigger and verify (
deploy.yaml) are project commands now, so one that writes the agent's home (~/.kube/cache,~/.config/...,~/.docker) gets "Read-only file system". Measured with a stand-in trigger throughprojectCommandon the merged tree (scratchpad/work/issue-798-r5/deploy-standin.mjs, no real tools):mkdir: cannot create directory '<home>/.kube': Read-only file system, the same for.configand.docker, and nothing exists in the home afterwards. Point the tool's cache or config outside the home (for exampleKUBECACHEDIR,DOCKER_CONFIG). The whole home stays read-only by the owner's decision; Sandbox: a per-project dependency cache outside the agent home, used only by sandboxed project commands (follow-up to #899) #912 is the follow-up. In the changelog's Upgrading section.npm ciwith a read-only home and no writable cache fails. Measured: exit 254, "Log files were not written due to an error writing to the directory: …/.npm/_logs". That's why~/.npmis a throwaway tmpfs.npm cithrough the real wrapper (projectCommand) with a fresh agent home works. Measured: exit 0, "added 76 packages in 5s". Afterwards the home holds only the empty.cacheand.npm, and~/.npmis[].npmw.sh, this user standing in for the agent user.scratchpad/work/issue-798/npm-through-wrapper.mjs.Cold caches. Every project command now downloads its dependencies again. It no longer shares a warm
~/.npmor~/.cachewith earlier commands (inferred). That costs time and network on every worktree setup and check.Anything a command writes in the home outside
~/.cacheand~/.npmnow fails withEROFS(inferred, from how the mount works; not run per tool):~/go/pkg/mod;GOCACHEis under~/.cache, so that part works);~/.cargo,~/.rustup,~/.m2,~/.gradle;pip install --userand other writes to~/.local;~/.local/share/pnpm;~/.config.tests.yamlto a path outside the home, such asGOMODCACHE,CARGO_HOMEorGRADLE_USER_HOMEunder/tmp(inferred).Browsers installed in the agent's
~/.cache/ms-playwrightare hidden inside the sandbox (the tmpfs covers them). A project runningnpx playwright testwith browsers installed there needsPLAYWRIGHT_BROWSERS_PATHset outside the home (inferred).macOS: every write under the home is denied,
~/.npmincluded. npm in a project command as the agent user will fail there (inferred from the Linux measurement; macOS isn't in CI).A checkout or worktree under the agent's home becomes read-only. The default layout keeps both in the coordinator's state directory, so the default is unaffected (inferred from
projectStateDirandinitInstance).An agent home that doesn't exist, or is
/, is now refused. Before, the command ran. I don't know of any install like that (guess).A link directly in the home or
~/.configwhose way out the agent can change, or a~/.cacheor~/.npmthat is a link, now refuses the run.~/.cacheat a bigger disk, or a dotfile manager linking into an agent-owned directory outside the home.useradd --create-homecopies/etc/skel, and the CI runner's skeleton has~/.ghcup -> /usr/local/.ghcup(CI, measured by the reviewer). Round 3 allows such root-only links, so they no longer refuse the run.None of these were loosened to make them work. They're listed for the owner, as the issue asks.
Existing installs: nothing that was closed reopens
All inferred from the diff; the existing tests below are the measured part.
~/.cachethatcredentials.yamlnames stays hidden (adversarial A5, measured).credentials.sh(corrected in round 15: this PR no longer changes it): the login-shell route stays closed through setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904'sclaude_api_key_login_ok, which runs as the agent throughsudo -H -uand a non-loginbash -c(run_as_boundedinscripts/setup/lib.sh; inferred from reading it).test/project-command-isolation.test.ts, a sandboxed command can't rename a directory above a hidden path:Read-only file system, each one by path (before: anybusyin stderr);busyand "contents stay writable" assertions;chmodto set up the attack. A sandboxed command can no longer do that, and each test now asserts the refusal (Read-only file system). The setup now does thechmodas the agent user outside the sandbox, which can still do it, so the wrapper's refusal is still exercised with every assertion it had before.test/coordinator.test.ts, the six instruction-eval tests:harness-configfromL3_human, so a config-folder skill or role change could open its PR without asking the owner. With the lock, that change is L3, and the owner is asked before the PR opens. In the full suite this showed as "no pr.opened event" (measured).openPr'sapproveLandingalready does for L3 changes, and continues.harness-configbesideagent-instructions.evalOnlyleaves out both, and the "no drop" test expects both.test/separate-users.test.ts:272, a check that runs git, run as the agent user (round 2, for the weakening gate):{ user: 'nobody', home: '/' }, which the new refusal of a/home rejects (CI shards 2/4 on Node 22 and 24, atb0c9d273);mkdtempSync,0755, removed infinally), and keepsassert.equal(r.status, 0, …)unchanged;test/project-command-home.test.ts(this PR's own file): the inward cache-symlink test now expects the run to be refused, which is stricter, instead of running with the target read-only.review.yamlleftharness-configout ofL3_human. A change to the config folder,.claude/orCLAUDE.mdis now L3 there too, so the owner is asked before its PR opens (inferred, fromsrc/coordinator.ts's L3 path, and measured by the updated coordinator tests). This is the tightening the issue asks for: before, such a repo could lower the human review oftests.yaml. In direct land mode it also stops these changes landing on their own. A merge-floor-only lock would have missed that, because direct mode lands L0 to L2 without the merge policy (inferred fromsrc/coordinator/landing.ts).Round 15 (
203aa3de)origin/main moved to
7b0a96a0(#904 and #886 merged); merged in203aa3de, signed off. Two conflicts, resolved as their merge notes said:src/review.ts: Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886's// check-table: never-shrinkmark kept onALWAYS_L3, with this PR's[SELF_MODIFICATION, HARNESS_CONFIG]. Since Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886 moved registrations to in-file marks, this PR's one new table,HARNESS_CONFIG_GLOBS, gets its own// check-table: never-shrinkmark (a tightening: its globs can't be taken out without the owner).LOCKED_CATEGORIES, which this PR grows, already carries Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886's never-shrink mark. This PR adds no lint test, so no// lint-testmark.scripts/setup/credentials.sh: setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904's side, its bounded calls (run_as_bounded,run_as_bounded_input,close_fds) kept. setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904's API-key check already runs as the agent user withsudo -H -uand never a login shell (inferred, fromclaude_api_key_login_okinlib.sh), which is what this PR's one-line change there did. So this PR no longer changescredentials.sh(measured:git diff origin/mainlists it no more), and the changelog fragment drops itscredentials.shline. The Risk class line and the "No login shell" note above say so.Body (round-14 delta, issuecomment-6311636738): What remains says "Any IPC channel outside the home" and names the shared network namespace as the cause; Still open says "(the sandbox shares the network and IPC namespaces)".
Tests run (measured, merged tree, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files):deploy-sandbox, the twelveproject-command-home*andproject-command-seatbelt-*files,project-command-isolation,review,merge-policy,harness-config-lock, the three changelog files anddocs-guardrails:# tests 228,# pass 228,# fail 0,# skipped 0. #886's mark tests (merge-policy-tip,policy-only-registration): 11 of 11. Typecheck clean,denylist: 1448 files clean,DCO: all commits signed off.Round 14 (text only,
a5277e54)From the round-13 second review (issuecomment-6310910989), which found the code held on Linux and on the deploy path. No code change; origin/main is still
9f9533aa, contained.docs/auth.md's one full list gains a bullet for files outside the home that a later process outside the sandbox reads and a project command can write: the checkout's.claude/settings.jsonhooks, in a worktree and in the main checkout (Worktree .claude/settings.json hooks written by a project command run unsandboxed at the next agent run #958), its.git/config(Security: agent-writable .git lets an agent plant config (filter, gpg.program, include) that the coordinator's git runs unsandboxed #926), and agent-owned paths such as$XDG_RUNTIME_DIRand/tmp/claude-<uid>(Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933). Generic wording, no steps.--unshare-ipcalone wouldn't close it (measured by the review).allow_same_usermode (admin-only, off by default), where project commands, a deploy's included, run without the sandbox.docs/auth.md, as do this body's Intent and Compromised component.allow_same_user; Still open adds the checkout's files and macOS as untested as a whole.Tests (measured):
docs-guardrailsand the three changelog files, 48 of 48;denylist: 1428 files clean.Round 13 (
c63db3b3)From the round-12 delta check (issuecomment-6310311222, passed) and the round-12 second review (issuecomment-6310318654). Commits
8d373588(origin/main9f9533aamerged: #919 deploy sandbox and #928; one auto-merged file,src/review.ts, no conflict) andc63db3b3. origin/main is now9f9533aa, contained (the earlier rounds' "origin/main is stilld2a549f4" was true at their heads, no longer).PROJECT_SANDBOX_REQUIRED=1):test/deploy-sandbox.test.ts(it drives the real bubblewrap through a stand-insudofirst on PATH, which records the user and runs the rest; the coordinator harness only runsgiton temp fixtures, so no other host tool) gives# tests 8,# pass 8,# fail 0,# skipped 0. The elevenproject-command-home*andproject-command-seatbelt-*files give# tests 54,# pass 54,# skipped 0. TMPDIR was the session scratchpad (not short); nothing failed on path length.docs/auth.md's project-command list adds a deploy's trigger and verify; the changelog's Upgrading section and What breaks state the cost (a deploy writing~/.kube/cache,~/.config/...or~/.dockergets "Read-only file system", measured with a stand-in) and point to Sandbox: a per-project dependency cache outside the agent home, used only by sandboxed project commands (follow-up to #899) #912.seatbeltProfilenow adds(deny file-write* (literal "<d>"))for each directory above the home (not/): the macOS form of the wrapper'sabove "$hr/.", so a home under a directory the agent owns can't be swapped by renaming that directory. Untested on a real macOS (inferred from seatbelt's rule semantics: a rename writes the source and the target path);docs/auth.md's macOS item says so until measured there.Fail-without (measured):
test/project-command-seatbelt-above.test.tswritten byte-identically (sha256285b7f30…) into a fresh worktree at68873520:# tests 2,# pass 1,# fail 1. The fail is the route; the pass is the control (a home directly under/, with nothing above it to pin).After (measured, merged tree, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files):deploy-sandbox, the eleven home and seatbelt files including the new one,project-command-isolation,docs-guardrailsand the three changelog files:# tests 147,# pass 147,# fail 0,# skipped 0.harness-config-lock,reviewandmerge-policy: 73 of 73. Typecheck clean,denylist: 1428 files clean,DCO: all commits signed off.Package size (measured, against main
9f9533aa): 1,484,470 / 4,971,214 to 1,490,683 / 4,992,294, so +6,213 / +21,080.dist/src/os/index.js+20,140 (round 13 adds +649),dist/src/review.js+940.Merge conflicts for whoever lands second: unchanged from rounds 7 and 10 (#886: keep both; #904: take #904's side; #920: keep both). #919 and #928 are merged.
Round 12 (
68873520)From the round-11 delta check (issuecomment-6310053568), which passed A to G and the single list. One commit,
68873520; origin/main is stilld2a549f4, already merged.realpathSync.native:seatbeltProfileForresolves the home with the OS's own realpath. Node's JSrealpathSynckeeps the case it was given, so on a case-insensitive disk a home configured with the wrong case might have passed the check while the profile matched no real path (guess about reach; macOS isn't in CI).projectCommand's macOS branch is now tested itself: it takesplatformandrealpathfor tests, as it takessandboxTool. Had that branch gone back toseatbeltProfile, no test failed before; the new tests would (measured, below).Fail-without (measured):
test/project-command-seatbelt-darwin.test.tswritten byte-identically (sha25681d2d76a…) into a fresh worktree atcc7e84db, built, run:# tests 5,# pass 0,# fail 5.After (measured, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files):project-command-seatbelt-darwin,-seatbelt-home,-home,-isolation,-home-way,-home-links:# tests 69,# pass 69,# fail 0,# skipped 0. Typecheck clean,denylist: 1421 files clean.Adversarial pass, rounds 11 and 12, on the macOS refusal (measured with
scratchpad/work/issue-798-r5/darwin-probe.mjs:projectCommandon this build with the platform injected, nothing spawned):sandbox-exec, with the profile that denies writes under it/normalHome, before the macOS check)//var/agent, resolving to/private/var/agent(resolver injected)/Volumes/x/Agent, resolving to lower case (resolver injected)/.//Whether a caller catches the refusal and runs the command some other way (inferred, from reading all six call sites in five files:
worktrees.ts×2,queue.ts,browser/run.ts,tune.ts,coordinator/shared.ts; since #919 merged in round 13, a deploy's trigger and verify also reach it throughcoordinator/shared.ts'ssh, viaproject()incoordinator/base.ts, anddeployStepinlanding.tsturns a thrown refusal into a failed deploy, never a run): each builds the argv withprojectCommandbeforespawn/spawnSync, with notryaround that call that falls back to running the command, so a refusal means nothing is spawned. Whether macOS's realrealpathreturns the stored case on a case-insensitive APFS volume is a guess here (no macOS in CI);.nativeis the OS's ownrealpath(3).Package size (measured, against main
d2a549f4): 1,487,990 / 4,983,557, so +6,085 / +20,431.dist/src/os/index.js+19,491 (round 12 adds +457),dist/src/review.js+940.Round 11 (
cc7e84db)From the round-10 second review (issuecomment-6309799136), which found the code held on Linux and measured two unnamed hard-link routes. One commit,
cc7e84db; origin/main is stilld2a549f4, already merged (measured:merge-base --is-ancestor).docs/auth.md's "What the read-only home does not stop" is now the only complete list. The changelog's Guardrail entry, this body's Still open, What remains and Compromised component point to it and name highlights only, without claiming to be complete. So the lists can't drift apart again (the class behind rounds 9 to 11's text findings).~/.claude*directories and directoriescredentials.yamlhides under the home are writable throwaway tmpfs mounts (D, measured there; no persistence); other users (A23) and links added mid-run (A5) as list items. (E), links inside what an allowed link reaches, was already in it.findno longer excludes*/.cache/*and*/.npm/*. The sandbox's throwaway mounts hide the real directories only by path, so a hard link into them is a write route (measured by the review). uv's cache makes such links, as pnpm's store does (inferred, from uv's documented default), and both are named as routes. It also says the check doesn't find (B). The same indocs/auth.md's pointer.docs/auth.md's A2b bullet says "through its other name, from wherever that name is" (a root-owned, agent-unwritable directory worked too, measured by the review).seatbeltProfileForrefuses, on darwin, a home whoserealpathSyncdiffers from the configured path or can't be resolved;projectCommanduses it. Not rewritten, so the admin sets the canonical path the message names (inferred that this closes the route: macOS isn't in CI, and the tests use real symlinked temp dirs on Linux plus an injected resolver for the/var→/private/varcase).Fail-without (measured):
test/project-command-seatbelt-home.test.tswritten byte-identically (sha25687aaaae3…) into a fresh worktree at3ec041b6, built, run:# tests 4,# pass 0,# fail 4(noseatbeltProfileFor).After (measured, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files):project-command-seatbelt-home,project-command-home,project-command-isolation,docs-guardrailsand the three changelog test files:# tests 93,# pass 93,# fail 0,# skipped 0. Typecheck clean,denylist: 1420 files clean.Package size (measured, against main
d2a549f4): 1,487,816 / 4,983,100, so +5,911 / +19,974.dist/src/os/index.js+19,034 (round 11 adds +1,307:seatbeltProfileForand its comment),dist/src/review.js+940.Round 10 (text only,
3ec041b6)From the round-9 reviews at
c9f5816f(final check issuecomment-6309458705, second review issuecomment-6309460729): the code held, and the text now matches it in full (superseded by round 11: the round-10 review found more, anddocs/auth.md's list is now the one full list). No code change.docs/auth.md: a live agent-user process is reachable through any socket outside the home too ($XDG_RUNTIME_DIR, the user bus,systemd --user, an abstract socket; measured by the second review with an abstract socket; Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933). On macOS,seatbeltProfilewalks no links, so a link out of the home is followed.docs/auth.mdand names highlights only) (deeper links, links inside what a link reaches, A2b, B9, sockets outside the home, A4, A5, A23, Project-command sandbox: refuse when run_as.agent_home differs from the agent user's passwd home #954, macOS links);Round 9 (docs and body only,
c9f5816f)From the docs-scoped second review at
1619a1b2(issuecomment-6309298646), which found the code held. No code change.docs/auth.md's list of what the read-only home doesn't stop is corrected and completed (superseded by round 11, which found it incomplete and made it the one full list):~/.bashrc, and a B9 reader that was~/.gitconfig. It now says that what a command writes reaches the agent-user process at the other end, outside the sandbox, which may keep it or act on it; the endpoint has to exist already. It points to Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933.lngetsInvalid cross-device linkfrom the cache tmpfs andRead-only file systemin the home (measured by the reviews).run_as.agent_home, and nothing compares it with the agent user's passwd home yet (Project-command sandbox: refuse when run_as.agent_home differs from the agent user's passwd home #954)./or to the command's working directory is refused too, as are more than 500 links in one directory, a way of more than 40 links, and a way through a directory the agent user can't search.This body: "What this can never do" → Still open now lists A2b, A4 and B9 as well; Compromised component names B9; the Round 8 note's "made before this release" is corrected.
Round 8 (docs only,
1619a1b2)docs/auth.mdnow says exactly what the read-only home leaves open: only links directly in the home and in~/.configare walked; a deeper link (R1), a hard link made outside the sandbox (A2b; round 8 said "made before this release", corrected in round 9: one made since, such as by pnpm's store, counts too) and a FIFO or socket already in the home (B9) are named, with a pointer to the After install checks. No code change (found by the round-7 adversarial review, issuecomment-6309175063).Round 7
From the round-6 adversarial review (issuecomment-6308854788) and the round-6 final check (issuecomment-6308866635, PASS). Commit
1c80eb36. origin/main is stilld2a549f4, already merged (measured:merge-base --is-ancestor).What changed:
~/.configoutside the home. With a root-owned home whose~/.configlinks to a root directory C outside it, each link in C was walked as<home>/<C>/<name>: a path that doesn't exist, so the link passed as dangling inside the home, unchecked and unpinned.C/git -> A/git, A the agent's own, let a command plantA/git/config(measured at3d24a65b, in the new test:PLANTED).links()now walks a link outside the home from/by its own path, and link mode starts withown=1wherever it starts. So the link is refused like any link through an agent-owned directory (measured: 125, "which the agent user owns", nothing planted). A control in the same shape with a root-owned target still runs (measured).unrestakes the path, so a way into/procsays(at '/proc'), not(at '/'), and/dev/stdoutsays(at '/dev/stdout'), not(at '/dev').PROJECT_SANDBOX_REQUIRED=1it fails instead.docs/auth.md, the changelog fragment and the code comment; A2's cache side effect in What remains,docs/auth.mdand the changelog fragment (not the code comment: corrected after round 8's final check). A13 is the coordinator's separate issue.Fail-without (measured): both new files written byte-identically (
project-command-home-config-out.test.tssha256a499d6b5…,project-command-home-refusal-at.test.ts591d99f3…) into a fresh worktree at3d24a65b, built, run withPROJECT_SANDBOX_REQUIRED=1:# tests 6,# pass 1,# fail 5. A1: expected'', actual'PLANTED'. The four at-path cases:expected: true,actual: false. The one that passes is the A1 control, which must keep running.After (measured, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files): the ten home and isolation files plusharness-config-lockandreview:# tests 105,# pass 105,# fail 0,# skipped 0. The changelog tests plusdocs-guardrails: 48 of 48. Typecheck clean,denylist: 1419 files clean,DCO: all commits signed off.Package size (measured, against main
d2a549f4): 1,487,415 / 4,981,793, so +5,510 / +18,667.dist/src/os/index.jsis +17,727 (round 7 adds +776), anddist/src/review.js+940.Merge conflicts for whoever lands second: #886 at
src/review.ts:472: keep both, the marker then the line. #904, now at76632556, atscripts/setup/credentials.sh:264: take #904's side. #920 (added after round 10, from the round-10 delta check): one hunk insrc/review.ts, #920'sDECISION_*lines against this PR'sHARNESS_CONFIGones: keep both.Round 6
From the verification-scoped second review (issuecomment-6308416906) and the round-5 delta check (issuecomment-6308482419). Commits
fbfb8a70(sandbox),0c07147c(the pins test) and3d24a65b(origin/maind2a549f4merged; clean).What changed (
src/os/index.ts,LINUX_SANDBOX, link mode only; credential walks unchanged):8bb61504, for root's device in a 0777 directory and the agent's own in a sticky one). Refusing fails closed and costs no device, as the review preferred./procor/sysis refused (A30), asadd()refuses a credential there. Pinning it bound the host's/procover the sandbox's own./devany more (A31, and the cost the round-5 body named): a symlink, a..or a directory the agent can write under/devon a link's way is refused, and so is a target there that isn't a device, or a dangling one. The coordinator decided this (option (b): refuse with a clear message, rather than run with no devices). Under/dev, nothing now depends on stickiness: a device in or below a writable directory there is refused, whether it is sticky or not.PROJECT_SANDBOX_REQUIREDset or not; only a namespace without bubblewrap, a tmpfs ormknodskips, and not underPROJECT_SANDBOX_REQUIRED=1. Round 5 had added a skip there (principle 9, found by the delta check); this removes it.docs/auth.md(the refusal list, the device sentence, the own-PID-namespace line), the code comments, and this body's "What this can never do", "Unmask anything", Compromised component and What remains.Fail-without (measured): I wrote the final
project-command-home-way.test.ts(as a new file,project-command-home-way-r6.test.ts, sha25601c0ad5b…, byte-identical) andproject-command-home-devproc.test.ts(sha25634e6d561…) into a fresh worktree at8bb61504, built it and ran them withPROJECT_SANDBOX_REQUIRED=1:# tests 17,# pass 5,# fail 12. The 12 are every new case:/proc/1/comm,/proc/self/status,/sys/kernel,/dev/stdout,/dev/fd/0,/dev/../etc/passwd,/dev: eachexpected: 125,actual: 0(the command ran);expected: 'character special file',actual: 'regular file'(planted); DF: statusexpected: '125',actual: '0'.The 5 that pass are the round-5 cases and the
/dev/nullcase, which must keep running.After (measured, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files, after merging main): the eight home and isolation files (-home-devproc,-home-way,-home-unres,-home-pins,-home-hops,-home-links,-home,project-command-isolation), plusharness-config-lock,reviewand the three changelog files:# tests 138,# pass 138,# fail 0,# skipped 0. Typecheck clean,denylist: 1417 files clean,DCO: all commits signed off.Merge conflicts for whoever lands second (from the delta check, unchanged): #886 at
src/review.ts:472, keep both (the// check-table: never-shrinkmarker, thenconst ALWAYS_L3 = [SELF_MODIFICATION, HARNESS_CONFIG];); #904 atscripts/setup/credentials.sh:264, take #904's side.CI at
3d24a65b: ci run 39548580286 was queued when this was written. The new cases run in the test shards through the round-5 bubblewrap step (user namespace: ok), with no skip possible underPROJECT_SANDBOX_REQUIRED=1(inferred from round 5's logs, the same step and env). The shard lines for the new cases are to be read from that run.Round 5
From the second adversarial review (issuecomment-6307781659) and the round-4 delta check (issuecomment-6307838032). Commits
a1a8bd4b(sandbox) and8bb61504(CI). origin/main is still41f6dd42, already merged (measured:merge-base --is-ancestor).What changed (
src/os/index.ts,LINUX_SANDBOX):above "$hr/."right afterhr=$R. Before, it was pinned only above a~/.claude*mask or a link into the home (route 1, A24)./dev(route 2, A25). The reviewer's literal fix,above "$x"for every device, would bind/devfor~/.bash_history -> /dev/nulland cost the sandbox its devices (A26, measured), so/dev/*targets are left as before.unresquotes the way (${at@Q}, withat=${cur:-/}), so a newline can't forge a separateproject command not run:line. Its reason for an unsearchable directory in link mode is now "goes through a directory the agent user can't search, so where it leads can't be checked". Link mode starts withown=1, so the old text blamed the agent for root's directory. The credential-mode text is unchanged (project-command-isolation.test.tsasserts it, and there it's about a directory under one the agent owns or can write).docs/auth.mdnow say what is pinned, the home's way and a device's included, with/devnamed as the exception. This body's "every directory above … is pinned", "What this can never do" and Compromised component are corrected in place. Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933 is linked under What remains.ci.yml, the test shards' bubblewrap step: installsuidmap, gives the runner's user subordinate ids when it has none, and, wherekernel.apparmor_restrict_unprivileged_usernsis 1, loads an AppArmor profile that lets/usr/bin/unshare(only) create user namespaces. The machine-wide restriction stays on. The step then checksunshare --user --map-root-user --map-auto --mountcan mount a tmpfs, so a broken setup fails the step.project-command-home-pins.test.tsand the newproject-command-home-way.test.tsnow fail rather than skip underPROJECT_SANDBOX_REQUIRED=1, which every shard sets (as the hops test does).unshare, measured by grep).Fail-without (measured): I wrote both new test files byte-identically (sha256
4ff5b259…forproject-command-home-way.test.ts,83b561d0…forproject-command-home-unres.test.ts) into a fresh worktree at26a39041, built it and ran them withPROJECT_SANDBOX_REQUIRED=1. Result:# tests 5,# pass 0,# fail 5:'# orig', actual'PLANTED';'# orig', actual'PLANTED';'character special file', actual'regular file';Q forgedexpected'0', actual'1';After (measured, temp HOME and TMPDIR,
PROJECT_SANDBOX_REQUIRED=1, explicit files):project-command-home-way,-home-unres,-home-pins,-home-hops,-home-links,-homeandproject-command-isolationgive# tests 61,# pass 61,# fail 0,# skipped 0. The changelog tests anddocs-guardrailsgive# tests 48,# pass 48. Typecheck is clean,denylist: 1403 files clean, andDCO: all commits signed off.How CI runs the namespace tests: in every test shard that selects them (they're affected by
src/os/index.ts), through the bubblewrap step above. At head8bb61504(ci run 39543735196, measured, not rerun), each shard's step loggeduser namespace: ok, and they ran, with no# SKIP:✔ the home's own way is pinned … root-owned and writable by everyone …,✔ … sticky and writable by everyone, like /tmp …,✔ a link to a device inside a root directory inside a writable one …,✔ a link whose way holds a newline and root's unsearchable directory …;✔ S1: a link into a root-owned directory inside a writable one is allowed …and✔ S2: a dangling link through a writable directory is refused …; Node 22 (job 119037104250):ok 819 - S1: …;ok 687 - a link whose way holds a newline and an unsearchable directory …and✔ …the same.How it was verified
f9422c4e). 9 of 9 failed (# tests 9,# pass 0,# fail 9):.worklane/tests.yaml with {}: touches app code(L1, not L3);~/.bashrc (appended): the sandboxed command wrote it: WROTE .bashrc …;renamed;~/.cache kept what a command wrote;wrote through the symlink into ~/.config;/: ran instead of being refused;deny file-write*rule.node --import ./dist/test/isolate.js --test dist/test/project-command-home.test.js dist/test/project-command-isolation.test.js:# tests 41,# pass 41,# fail 0,# skipped 0.… dist/test/harness-config-lock.test.js dist/test/review.test.js dist/test/merge-policy.test.js dist/test/merge-plan-scope.test.js dist/test/pr-rules.test.js dist/test/docs-guardrails.test.js dist/test/project-env.test.js dist/test/adversarial/adversarial.test.js:# tests 191,# pass 178,# fail 0,# skipped 0.26a39041, origin/main41f6dd42merged in):test/project-command-home-pins.test.tsbyte-identically (sha2568154e5b6…) into a fresh worktree at7f3dfc8b, built it and ran it. It runs inunshare --user --map-root-user --map-auto --mount(no sudo), on a tmpfs over/mntin that private mount namespace. Result:# tests 2,# pass 0,# fail 2.S1 planted:, expected'# root', actual'PLANTED'.S2 planted:, expected'', actual'PLANTED'.# tests 311,# pass 307,# fail 0,# skipped 4.denylist: 1401 files clean.unshare --map-auto), and it skips with that reason where they're missing. Whether CI's runners have them shows in CI. S1 and S2 are covered locally and by the reviewer's driver either way.# SKIP needs \unshare --map-auto`(measured, run 39540413942), even withPROJECT_SANDBOX_REQUIRED=1`, so nothing in CI covered S1 or S2. See Round 5 for the fix.7f3dfc8b; origin/main is stilla5df2254, already merged):test/project-command-home-hops.test.tsbyte-identically (sha256f71b66b0…) into a fresh worktree at47b7702b, built it and ran it. Result:# tests 3,# pass 0,# fail 3.not refused:(expected 125, actual 0)./tmp:not refused:(expected 125, actual 0)./usr/share,/etc/passwd,/dev/null, and a dangling link into/): refused (expected 0, actual 125).node --import ./dist/test/isolate.js --teston the three changelog test files and ondist/test/project-command-home-hops.test.js,project-command-home-links.test.js,project-command-home.test.jsandproject-command-isolation.test.jsgives# tests 93,# pass 93,# fail 0,# skipped 0.denylist: 1377 files clean.decb681a(measured, not rerun): both real-user ci-slow jobs and test shard 3/4 failed./usr/local/.ghcupand/usr/shareare writable by the agent user and the test user, so round 3's first rule refused them.7f3dfc8bpins such directories instead of refusing them. Its CI is pending.47b7702b, origin/maina5df2254merged in):test/project-command-home-links.test.tsfirst and ran it onb0c9d273before the change. Result:# tests 10,# pass 1,# fail 9, every failurenot refused:. The one that passes checks that links inside the home still run.node --import ./dist/test/isolate.js --teston the following files gives# tests 306,# pass 302,# fail 0,# skipped 4:dist/test/project-command-home-links.test.js,project-command-home.test.js,project-command-isolation.test.js;harness-config-lock.test.js,review.test.js,merge-policy.test.js;coordinator.test.js;changelog.test.js,changelog-fragments.test.js,changelog-fragments-selfcheck.test.js.denylist: 1376 files clean.projectCommandwith a temp home:status 0, both refusals on stderr, and~/.bashrcunchanged.npm run typecheckis clean, andnpm run check:denylistprintsdenylist: 1322 files clean.scripts/ci/project-command-real-users.mjs(ci-slow's real-users job, with realsudo -u <agent>) now also checks two things:~/.bashrcand~/.gitconfigis refused (Read-only file system). Round 2: this is asserted on stderr, together with~/.bashrcbeing unchanged when read as the agent user. A failed>>is reported before any2>&1applies, so atb0c9d273the stdout assertion failed although the sandbox held (CI, measured by the reviewer);project-command-home.test.jsthere withPROJECT_SANDBOX_REQUIRED=1.fullsuite.sh, temp HOME and TMPDIR; measured): 4,078 tests, 3,992 pass, 18 fail, 55 skipped. The 18 failures:coordinator.test.ts, caused by the lock. Fixed in the test commit above. Afterwardscoordinator.test.jsgives# tests 146,# pass 142,# fail 0(measured).fix-forward-coordinator.test.js). They fail the same way on the base commitf9422c4ewithout this change (measured:# fail 2,both PRs open).isolate.test.jstests. They fail because my scratch TMPDIR is long:…/SingletonSocket is 164 bytes(measured message), which is environmental.testrun-env-argvand others):listen EADDRINUSE … docker.sockunder that TMPDIR (measured message). Unrelated (inferred: they don't reachprojectCommandorreview.ts).bench.test.tssandbox-probe test and 1add-project.test.tsAuth docs: who can read agents' credentials and whether the engine stores a token match the code (coordinator sudo, dashboard token paste) #711 test. Unrelated (inferred: neither reachesprojectCommand,seatbeltProfile,review.tsor the API-key branch ofcredentials.sh). I didn't rerun them on the base, because both set a PATH that reaches host tools. CI runs them.separate-users,network*,portabilityandchange-files-renames. They set a PATH that reaches/usr/binand can call real host tools, or need real sudo, which the tooling rule leaves to CI.Overlap with open PRs (measured:
git merge-tree --write-treeof this head against each head)Round 2, at head
47b7702b: #893 (33ef90c3) and #903 (1b79f26e) are clean. #886 and #904 (f63b5373) have the same single conflicts as below, with the same resolutions. #886's head is nowb0772704, with the same one-lineALWAYS_L3conflict.Round 4, at head
26a39041(measured):33ef90c3and Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #9192688074c: clean.e79b6cc7: the oneALWAYS_L3line insrc/review.ts; keep both.9d546710:scripts/setup/credentials.sh; take setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904's side.c65cf0ae: conflicts insrc/coordinator/merge-flow.ts,src/review.tsandsrc/vacuity.ts. It conflicts with origin/main41f6dd42itself in the same three files (measured), so this is not from this PR.Round 3, at head
decb681a(measured): #89333ef90c3and #9031b79f26eare clean. #886b0772704has one conflict insrc/review.ts: keep the marker line, then[SELF_MODIFICATION, HARNESS_CONFIG]. #904f63b5373has one conflict inscripts/setup/credentials.sh: take #904's side.9e2fee7c, and again at its later head33ef90c3. It touchessrc/runner.ts,src/read-denials.tsandsrc/hook-imports.ts(Claude Code's own sandbox settings). This PR touches none of those, and Sandbox: a task's hidden paths and the project's secret paths are denied by the OS sandbox and Read rules, not only the hook (#890) [PROTECTED: owner merges] #893 touches none of this PR's files.1960014f: one conflict, one line, insrc/review.ts. Merge policy: a check table or lint test registers itself where it's declared; only removing or loosening one waits (#869) [PROTECTED: owner merges] #886 adds// check-table: never-shrinkaboveconst ALWAYS_L3 = [SELF_MODIFICATION];, and this PR changes that line to[SELF_MODIFICATION, HARNESS_CONFIG]. The resolution keeps both: the marker line, then the new array.1843b180: one conflict, inscripts/setup/credentials.sh, at thesudo -iu <agent> claude auth statusline that both PRs replace. setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904 replaces it withclaude_api_key_login_ok(lib.sh:sudo -H -u … claude auth status --json, also no login shell), so the resolution is to take setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904's side.src/review.ts), head6cb3bf61: clean.src/os/index.ts,src/review.tsorci-slow.yml): clean.origin/main: clean at61858dc9, and again ata5df2254, which is now merged into this branch.src/review.tsis Lane A's file (LANES.md). The brief for this build asked for the harness-config lock, so it is here, as its own commits (thereview:commit and a follow-up that only moves the definition) so it can be split out if Lane A prefers.Package size
Round 6 (measured:
npm pack --dry-run --jsonin a worktree at the new origin/maind2a549f4and in this branch with it merged, both built):d2a549f4Per file:
dist/src/os/index.js+16,951 (76,947 to 93,898; round 6 alone adds +3,696 over round 5's 90,202: the device,/proc//sysand/devrefusals and their comments);dist/src/review.js+940, unchanged.Round 5 (measured:
npm pack --dry-run --jsonin a worktree at26a39041and in this branch, both built):26a39041gives 1,474,024 / 4,939,511, the same as round 4 measured; the new head gives 1,474,452 / 4,940,824. origin/main is still41f6dd42, so against main's round-4 figures:41f6dd42Per file:
dist/src/os/index.js: +13,255 (round 4's +11,942, plus round 5's +1,313: the home's way pinned, the device target's pins, the quoted link refusal and its wording, and their comments);dist/src/review.js: +940 (the harness-config lock), unchanged in round 5.Earlier deltas (all measured): +1,833 / +6,011 at
47b7702b, confirmed by the reviewer; +3,059 / +10,029 atdecb681a; +3,457 / +11,448 at7f3dfc8b; and +3,848 / +12,882 at26a39041.That is well under
PACKAGE_BUDGET, which is not changed.Correction: round 1 said +1,161 / +3,283. The reviewer measured +1,177 / +3,314 at
b0c9d273againstf9422c4e, so my round-1 numbers were from an earlier build.Adversarial pass
Each attack ran as a project command through the real wrapper, against a temp agent home, with this user standing in for the agent user (
scratchpad/work/issue-798/adversarial.mjs). All results are measured.~/.bashrcthrough/proc/self/root/<home>and/proc/1/root/<home>~/.bashrcinto the checkout or/tmp, then write the linklnfails: cross-mount)~/.bashrcmade before the change (outside the sandbox)~/.bashrc. Residual, belowunshare -Urm, thenmount -o remount,bind,rw <home>,umount <home>andumount -l <home>credentials.yamlnames under~/.cachemount -t tmpfsover the home from inside..(~/.cache/../.bashrc)~/.npm/_npxfor later~/.cachea symlink into~/.config, then write through it~/.profile,~/.ssh,~/.local,~/.cacheor~/.npmlinked to an agent-writable place outside the home, then write through the linkb0c9d273: writes landed outside, and survived. Now held: refused before anything runs; in the tests../outside/gitconfig), a dangling outward link whose target the command would create, and an outward link in~/.config~/.profile -> <outside>/hop/profilewith<outside>/hop -> ~/dotfiles(resolves inside the home), then re-aim the hop and write~/.profile47b7702b: the plant landed. Now held: refused, naming the link and the hop; in the tests/tmp(writable by the agent, not owned by it)/usr/share,/etc/passwd,/dev/null, a dangling link into/)47b7702bthey were refused, which broke CI~/.profile -> W/D/c, W root's and writable by everyone (not sticky), D and c root's;mv W/Daway, recreate it, plantc7f3dfc8b:~/.profilereadPLANTEDoutside. Now held: every directory above the target is pinned, andmvfails.~/.profilereads# root(measured;project-command-home-pins.test.ts)~/.npmrc -> W/D/missing7f3dfc8b. Now held: refused (125), because a directory on its way is writable (measured; in the tests)T/a/home,athe agent's, T root's 0777 or sticky 1777, no~/.claude*;mv T/aaway, recreate the home's path, plant.bashrc26a39041(.bashrcreadPLANTED). Now held:mvgetsDevice or resource busy,.bashrcreads# orig(measured;project-command-home-way.test.ts, and the reviewer'sns.shrerun: HA, HC, HS)~/.x -> W/D/n, n a character device (a bind of/dev/null), W root's 0777, D root's 0755;mv W/Daway, write n as a file26a39041(n became a regular file). Now held:mvgetsbusy, n is still acharacter special file(measured; in the tests)above "$x"for every device, on~/.bash_history -> /dev/null/devonto itself, which gives/devnodev(measured:bwrap --dev-bind / / --bind /dev /devthenecho x > /dev/nullgetsPermission denied). So devices under/devaren't pinned; the hops test's/dev/nullcase passes (measured)ev<newline>project command not run: FORGED LINE/locked,lockedroot's 070026a39041: a forged second line, and "the agent user owns or can write" for root's directory. Now held: one line, the way$'…'-quoted, "a directory the agent user can't search" (measured; in the tests)/proc/self/fd,O_PATH,openat,/proc/1/root, xattr, hard links, a nestedunshare -Urmremount, caches,crontab), rerun at the new head with the reviewer'sns.sh$XDG_RUNTIME_DIRstand-in is still writable: #933~/.x -> W/n, n a char device (a realmknod c 0 0in the namespace), W root's 0777; and the agent's own device in a sticky 1777 W;rm W/n, write a file8bb61504: n became aregular filein both (measured). Now held: refused (125) before the run, "a device in or below '…/W', a directory the agent user can write" (measured; in the tests)~/.x -> /proc/1/comm,/proc/self/status,/sys/kernel,/dev/stdout,/dev/fd/08bb61504all ran, status 0; the delta check measured host systemd as PID 1 inside). Now held: refused (125), "goes through /proc or /sys" or "goes through a symlink under /dev" (measured; in the tests)/dev/shm(a private tmpfs in the namespace) with root'sD/n,mv /dev/shm/Dand plant; a device in a writableWunder a private/dev/shm; a root file under/dev/shm/D;~/.x -> /dev/../etc/passwd;~/.x -> /dev8bb61504(n became aregular file; the rest ran, status 0, measured). Now held: all refused (125) before the run (measured; in the tests)~/.bash_history -> /dev/nullafter these refusalsbwrap,/dev/nullis writable (measured;project-command-home-devproc.test.ts, and the hops test)~/.config -> C(root's, outside the home),C/git -> A/git, A the agent's own; plantA/git/config3d24a65b(PLANTED, status 0, measured). Now held: refused (125), nothing planted; a root-owned target in the same shape still runs (measured;project-command-home-config-out.test.ts)/usr/local/.ghcupis (~/.ghcup-like -> /var/tmp); write through it, write directly,mvthe directoryRead-only file system,mvfails (measured,adversarial4.mjs). Atdecb681athis shape was refused, which kept CI red~/.rt -> /run/user, then write into the agent-owned/run/user/<uid>through itRead-only file system, because the target is bound read-only. A direct write to/run/user/<uid>is read-only in that sandbox too (measured,scratchpad/work/issue-798/adversarial3.mjs)mv ~/.bashrc,rm,chmod 777 ~,ln -s … ~/.cargo,mkdir ~/.config/autostart,touch ~/.hushlogin)/, with nothing to make read-onlyreview.yamldropsharness-configfrom L3, sets its globs to[]or to an unrelated glob, in the engine's repo and in a managed appFixed because of the pass:
/devout of the device pins./procor/sys, and any link whose pins would bind/dev. All became tests. My pass hadn't tried/proc.~/.configoutside the home from/. It became a test, with a control. A5 and A2's cache side effect are named.The residuals are open; I left them as the owner's call rather than loosening anything:
Invalid cross-device link,Read-only file system, measured by the reviews). The wrapper can't cheaply refuse hard-linked files: pnpm's store hard-links files in the home on purpose (inferred), though each such link is a write route all the same. Suggested one-time check on upgrade, as root:find <agent home> -xdev -type f -links +1 -not -path '*/.cache/*' -not -path '*/.npm/*'.~/.sshholdingconfigas a link to/tmp/x. The wrapper walks only the links directly in the home and in~/.config; one deeper down is followed, so a command can write where it leads or re-aim a hop (inferred from the code; the same mechanism as A13 and A16).find <agent home> -xdev -type l, then inspect each withnamei -l <link>. Any entry outside the home that the agent owns or can write makes it unsafe.readlink -malone would miss A16's shape, so that check was replaced.doctor,check.shor the updater, that reports or refuses such links. Once it exists, the optional After install line can go./usr/local/.ghcup/bin -> /tmp/xon a runner where that directory is world-writable: the target is bound read-only, but a link inside it is followed wherever it leads (inferred). Such a link would come from outside the sandbox. A command can't create one, because the target is read-only to it.G/env, and agent A read it through~/.ghcup/env.useradd -m -k <empty dir>), so a new agent user's home holds no such link./tmp/claude-<uid>/(Claude Code's temp directory),$XDG_RUNTIME_DIR(the second review planted asystemd/userunit in a stand-in, measured). That's outside Sandbox: a project command can't plant config in the agent's home that a later unsandboxed agent-user process reads (bashrc, gitconfig); lock harness-config review #798's "home" scope (inferred). Filed as Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933.What remains
$XDG_RUNTIME_DIR, includingsystemd --userruntime units, and/tmp/claude-<uid>) and live agent-user services.docs/auth.md, "What the read-only home does not stop" (since round 11; this body and the changelog name highlights only, so they can't drift from it). Among them: A2b, B9, R1, A23 and A4./dev, a symlink there (/dev/stdout,/dev/fd/N) or a..out of a directory there, or whose target under/devisn't a device, used to get/devpinned, which cost the sandbox its devices (measured:/dev/null: Permission denied). Such a link is now refused before the run, naming it, rather than run with no working devices. A home with such a link that ran on main is refused now: a cost to such installs, not a reopened route. How many homes have one is a guess: few.run_as.agent_homewith the agent user's passwd home and refuse a mismatch. Not built here.$XDG_RUNTIME_DIR, the user bus,systemd --useror a loopback port. Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933.seatbeltProfilewalks no links, so a link leading out of the home is followed and written through there.docs/auth.md, the changelog fragment and the code comment.~/.x -> ..) is bound read-only over the home's own mounts, so for that layout~/.cacheand~/.npmare read-only too. Named indocs/auth.mdand the changelog fragment (not in a code comment).What this can never do
docs/auth.md, "What the read-only home does not stop"; "Still open" below names highlights.26a39041this line was false for a home under a directory the agent could write two levels up with no~/.claude*(A24): the home's way was pinned only by accident. It is now always pinned (measured, in the tests).~/.config(wherever~/.configleads, since round 7: A33) is walked hop by hop. The run is refused in these cases, the same as the changelog's list:/dev: in or below one) (A29, A31);/procor/sys(A30);/devanything but a device, or a device through a symlink, a..or a writable directory there (A31);Also refused, as before: a link reaching
/or the command's working directory, and more than 500 links in one directory./dev(A26). Since round 6 a device in a directory the agent can write is refused (A29), and under/devone in or below any directory there the agent can write, so nothing rests on/dev's writable directories being sticky (A31).8bb61504this line was false for a device directly in a directory the agent can write (~/.x -> W/n): nothing held n, so a command removed it and wrote a file there (measured, A29).docs/auth.md, "What the read-only home does not stop" (since round 11). Highlights only, not a complete list:~/.cacheand~/.npmincluded (A2b, and the round-10 review's A), or to a file an allowed link reaches (the round-10 review's B): a command writes the file through its other name;.claude/settings.jsonhooks, in worktrees and the main checkout (Worktree .claude/settings.json hooks written by a project command run unsandboxed at the next agent run #958), and its.git/config(Security: agent-writable .git lets an agent plant config (filter, gpg.program, include) that the coordinator's git runs unsandboxed #926);/procor/dev(since round 6): a link whose way enters/procor/sysis refused, and so is one whose pins would bind/dev.8bb61504(and since link mode came in) this line was false. A link through/proc(~/.x -> /proc/1/comm,/dev/stdout,/dev/fd/N) pinned/proc, which bound the host's/procover the sandbox's own: PID 1 inside was host systemd, and 126 to 162 processes were visible (measured by the round-5 delta check; Project commands (checks, setup, full runs) run agent-written code as the agent user without a sandbox: they must not reach the agent's credentials #713 had closed that). Refused since round 6 (A30, measured, in the tests)./): it's refused withSANDBOX_UNAVAILABLE, naming the fix.review.yamltakeharness-configbelow L3 or out of the merge floor, or shrink its globs.merge-policy.ts: the floor picks the lock up throughLOCKED_CATEGORIES.Compromised component
claude, outside the sandbox): unchanged. It owns its home and can still write it directly. This PR stops project code, which is less trusted, from doing so through the sandbox.docs/auth.md, "What the read-only home does not stop"; among it: hard links made outside the sandbox into the home or to what an allowed link reaches (A2b, and the round-10 review's A and B), R1, A23, A4, B9 and any IPC channel outside the home (Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933), files outside the home that a later unsandboxed process reads, such as the checkout's.claude/settings.jsonhooks and.git/config(Worktree .claude/settings.json hooks written by a project command run unsandboxed at the next agent run #958, Security: agent-writable .git lets an agent plant config (filter, gpg.program, include) that the coordinator's git runs unsandboxed #926), agent-owned paths outside the home (Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933), A5, Project-command sandbox: refuse when run_as.agent_home differs from the agent user's passwd home #954, macOS (the whole profile untested on a real Mac), andallow_same_usermode, where there is no sandbox at all. Round 5 closed the second review's routes 1 and 2 (A24: renaming a directory above the home; A25: renaming the directory above a device target) and its forged refusal line (A27). Round 6 closed a device replaced in a writable directory (A29), the host's processes shown through a home link into/proc(A30, open from link mode until round 6, against Project commands (checks, setup, full runs) run agent-written code as the agent user without a sandbox: they must not reach the agent's credentials #713), and a non-sticky writable directory under/dev(A31).review.yaml(written by an agent or a contributor): can no longer lower harness-config. It can only add globs.Release note
changelog.d/899-agent-home-read-only.mdhas three sections:namei -l);Round 2 added the outward-link refusal to Upgrading, and round 3 rewrote it as the per-hop rule. In round 3 I also corrected two false claims: "except through a link deeper down" and "a symlink that leads somewhere else in the home is fine".
Measured:
test/changelog.test.tsandtest/changelog-fragments*.test.tsgive# tests 39,# pass 39.New dependencies
None.