Skip to content

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

Merged
jayden1711 merged 29 commits into
mainfrom
issue-798
Oct 21, 2026

Conversation

@jayden1711

@jayden1711 jayden1711 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

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 directory credentials.yaml hides 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's review.yaml also can no longer lower harness-config, where tests.yaml lives: 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.yml and (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:

  • (A) Make the home read-only inside the sandbox. This closes the write side for every reader at once. That includes readers the engine doesn't start: an admin's sudo -u <agent> -i login shell, cron, and the agents' own claude. Claude Code sources ~/.bashrc for its shell snapshot (inferred from Claude Code's documented shell-snapshot behaviour; not measured here).
  • (B) Give unsandboxed agent-user processes a clean HOME and fixed config (GIT_CONFIG_GLOBAL=/dev/null, --norc, ...). This only covers the processes the engine starts. It also can't cover the agents' own claude, whose login is in ~/.claude under 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_SANDBOX in src/os/index.ts):

  • Order of mounts. After the existing self-binds, and before the masks, the wrapper adds --ro-bind <home> <home>.
    • The ro-bind covers the self-binds under the home, so nothing in the home can be renamed: you get EROFS where you used to get EBUSY.
    • The masks still hide the credentials on top of it.
  • Caches. ~/.cache and ~/.npm each get a --tmpfs that starts empty and is gone when the command ends.
    • They are never persistent: ~/.cache holds code that unsandboxed processes run (corepack, go-build, Playwright browsers), and ~/.npm/_npx holds code that npx runs (inferred). A writable persistent cache would just be another place to plant.
    • The wrapper creates the cache directory in the real home when it's missing, because npm fails on a read-only cache (measured below).
    • It mounts the tmpfs only over a real directory. A cache that is a symlink, wherever it points, refuses the run (round 2).
  • Homes it refuses. A home that isn't a directory, or is /, is refused before anything runs. With no home to make read-only, a command could create one and plant config in it.
  • The home's own way is always pinned (round 5). The home and every directory above it are bound onto themselves (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 at T/a/home with a the agent's, T writable and no ~/.claude* let a command rename T/a away, recreate the home's path and plant .bashrc (route 1 of the second adversarial review, measured).
  • Links in the home, checked hop by hop (round 2, reworked in round 3). Before mounting anything, every symlink directly in the home, or directly in ~/.config, is walked from the home. The walk goes one component and one link at a time, with walk()'s own rule from the credential paths, in "link mode" (hop).
    • Refused (7f3dfc8b). The refusal names the link, the hop that makes it unsafe, and the sudo -u <agent> rm -- that removes it, with every link listed at once. The cases:
      • A hop that could be re-aimed: a link found in a directory the agent can write. This is walk()'s own credential rule. So a link that resolves back into the home through an agent-writable hop fails: ~/.profile -> /x/hop/profile with /x/hop -> ~/dotfiles. Round 2's readlink -m check missed this (the review measured it).
      • A hop that is the agent's own: a directory or file outside the home that the agent user owns (fail closed).
      • A dangling link whose way outside the home passes through any directory the agent can write, or stops in one (round 4, 26a39041). A command could create its target, or rename a directory below the writable one away and recreate it (S2).
      • A link that reaches /, or a directory holding the command's working directory, because binding it read-only would take the command's own directory too.
    • Pinned and allowed (corrected in round 4):
      • A directory on the way that the agent can write but doesn't own (root's world- or group-writable) is bound onto itself, together with the directories above it.
      • Any link found in such a directory is still refused (the first refusal case).
      • Since 26a39041, every directory above the link's final target is pinned too, as add() pins a credential's way (above "$x"). The same holds above an allowed dangling link's stop point.
      • Round 4's claim, "every directory above … is pinned", was false for a device target: its bind was skipped (continue) before above "$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 /dev onto itself costs the sandbox its devices (measured: bwrap --dev-bind / / --bind /dev /dev gives /dev nodev, and echo x > /dev/null gets Permission 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 /dev the agent can write is refused, and so is any way through such a directory, and a device in a writable directory anywhere (A29, A31).
      • Round 3's claim, "with everything above it, as 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 plant c (S1, measured by the reviewer and in the new tests).
      • Example: the CI runner's skeleton link ~/.ghcup -> /usr/local/.ghcup. On the runner, /usr/local/.ghcup is writable by the probe's agent user (CI, measured: decb681a's refusal named it as "owns or can write"). Pinning instead of refusing is in 7f3dfc8b.
    • Bound read-only: what an allowed link reaches outside the home is bound read-only in this agent's sandbox (lbinds), so nothing under it is written there, through the link or directly (measured):
      • ~/.rt -> /run/user: a write to ~/.rt/<uid>/x got Read-only file system;
      • ~/.ghcup-like -> /var/tmp (root's, world-writable): writes through the link and directly to /var/tmp got Read-only file system, and mv /var/tmp failed;
      • the reviewer's S1 to S5 driver, rerun at 26a39041 in a user namespace: S1 runs and ~/.profile still reads # root; S2 is refused (125); S3, S4 and S5 hold, as before.
    • Cross-user residual (named in round 4): the pins and the read-only bind hold only in this agent user's sandboxes. If a link's target is a directory that other users can write, those users still can write it. That includes another instance's agent user, through its own sandboxed project commands.
      • The reviewer measured this: agent B planted G/env, and agent A then read it through ~/.ghcup/env.
      • It's not a regression: main followed every link, and any user could write such a directory there too.
      • It is the cost of pinning instead of refusing writable directories. The stricter rule would refuse such a link, but it would also refuse the CI runner's skeleton link.
    • Also allowed: a device, such as ~/.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 /dev through a symlink, a .. or a writable directory, round 6), and a link that stays inside the home. Refused since round 6: a way into /proc or /sys, and anything under /dev but a device.
    • How hops are counted: inside the home a hop is fixed, because the home is read-only inside the sandbox. A directory above the home is pinned when writable.
    • Note for the reviewer: your round-3 brief said to refuse any hop the agent "owns or can write". Literally applied, that refuses the runner's ~/.ghcup (its target is world-writable) and keeps CI red. So I followed the other half of the brief, "the same rule walk() already applies to credential paths": walk() refuses a link in a writable directory and pins a writable directory, and add() pins every directory above the final path.
      • Since round 4, with every directory above the target pinned and the target bound read-only, neither re-aiming nor writing through the link is possible from this agent's sandbox. This is measured for S1 to S5, the hop bypass and the runner's shape.
      • Round 3 claimed this unconditionally, and that was false: S1 and S2 broke it.
      • It still doesn't stop other users who can write such a directory (the cross-user residual above).
      • It's the owner's call if the stricter refusal is wanted instead.
    • Bounded: past LINK_CAP (500) symlinks in one of those directories, the run is refused rather than run with some unchecked.
    • Cost (measured: one project command, a temp home 10 directories deep, 500 inward links in ~/.config):
    • Credential walks are unchanged: hop does nothing outside link mode (inferred from the code; the isolation tests pass, measured).
    • Links deeper in the home are not walked (residual R1, below).
  • macOS. seatbeltProfile adds (deny file-write* (subpath "<home>")) and refuses a home of /. Since round 11, seatbeltProfileFor (what projectCommand uses 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:

  1. No login shell for the admin sign-in. credentials.sh really ran sudo -iu <agent> claude auth status as 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_ok through run_as_bounded, sudo -H -u and 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 changes credentials.sh. What follows is how this PR had changed it.) It now runs sudo -H -u <agent> claude auth status --json from /, as claude-account.sh already does. The displayed sign-in command (sudo -u <agent> -i claude auth login) is still unchanged. It appears in src/claude-accounts-retired.ts and scripts/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).
  2. Home read-only, with an explicit writable list. Done as above. What breaks is listed below.
  3. harness-config locked in code. It is now in LOCKED_CATEGORIES and ALWAYS_L3, so it joins the merge policy's floor through LOCKED_CATEGORIES with no change to merge-policy.ts.
    • It is always L3 and always waits, 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 the built-in globs (a union), so a repo that extended it loses no path.
  4. Tests. Below.

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 through projectCommand on 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 .config and .docker, and nothing exists in the home afterwards. Point the tool's cache or config outside the home (for example KUBECACHEDIR, 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 ci with 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 ~/.npm is a throwaway tmpfs.

  • npm ci through 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 .cache and .npm, and ~/.npm is [].

    • Setup: this repo's worktree, npm through npmw.sh, this user standing in for the agent user.
    • Script: scratchpad/work/issue-798/npm-through-wrapper.mjs.
  • Cold caches. Every project command now downloads its dependencies again. It no longer shares a warm ~/.npm or ~/.cache with earlier commands (inferred). That costs time and network on every worktree setup and check.

  • Anything a command writes in the home outside ~/.cache and ~/.npm now fails with EROFS (inferred, from how the mount works; not run per tool):

    • Go's module cache (~/go/pkg/mod; GOCACHE is under ~/.cache, so that part works);
    • ~/.cargo, ~/.rustup, ~/.m2, ~/.gradle;
    • pip install --user and other writes to ~/.local;
    • pnpm's store under ~/.local/share/pnpm;
    • shell history files;
    • tools that save state under ~/.config.
    • Fix for an affected project: set that tool's cache variable in tests.yaml to a path outside the home, such as GOMODCACHE, CARGO_HOME or GRADLE_USER_HOME under /tmp (inferred).
  • Browsers installed in the agent's ~/.cache/ms-playwright are hidden inside the sandbox (the tmpfs covers them). A project running npx playwright test with browsers installed there needs PLAYWRIGHT_BROWSERS_PATH set outside the home (inferred).

  • macOS: every write under the home is denied, ~/.npm included. 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 projectStateDir and initInstance).

  • 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 ~/.config whose way out the agent can change, or a ~/.cache or ~/.npm that is a link, now refuses the run.

    • Who it hits: an admin who pointed ~/.cache at a bigger disk, or a dotfile manager linking into an agent-owned directory outside the home.
    • The refusal names each link, the hop, and how to remove it.
    • Correction: round 2 guessed that no real layout has an outward link, because agent users are service accounts with empty homes. That was wrong. useradd --create-home copies /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.
    • Stow-style dotfiles kept inside the home still work (measured, in the tests).

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.

  • The sandbox only gets tighter. Every existing mask, self-bind and refusal is kept and applied after the read-only home. A credential under ~/.cache that credentials.yaml names stays hidden (adversarial A5, measured).
  • harness-config can only be raised: the change adds globs and always-L3 status, and removes neither.
  • 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's claude_api_key_login_ok, which runs as the agent through sudo -H -u and a non-login bash -c (run_as_bounded in scripts/setup/lib.sh; inferred from reading it).
  • Existing tests changed. The fail-without for these is builder-measured: the tests change along with the code.
    • test/project-command-isolation.test.ts, a sandboxed command can't rename a directory above a hidden path:
    • The unsearchable-directory tests (five) used a sandboxed chmod to 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 the chmod as 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:
      • Their fixture drops harness-config from L3_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).
      • The helper now asserts that the change is L3 and that no PR opens before the answer. It then approves, as openPr's approveLanding already does for L3 changes, and continues.
      • The merge's high-risk reasons now name harness-config beside agent-instructions. evalOnly leaves 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):
      • it used { user: 'nobody', home: '/' }, which the new refusal of a / home rejects (CI shards 2/4 on Node 22 and 24, at b0c9d273);
      • it now gets a real temp directory as the home (mkdtempSync, 0755, removed in finally), and keeps assert.equal(r.status, 0, …) unchanged;
      • no assertion was changed or removed.
    • 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.
    • No assertion was removed. Every check either still holds or now checks the stricter outcome.
  • A behaviour change for repos whose review.yaml left harness-config out of L3_human. A change to the config folder, .claude/ or CLAUDE.md is now L3 there too, so the owner is asked before its PR opens (inferred, from src/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 of tests.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 from src/coordinator/landing.ts).

Round 15 (203aa3de)

origin/main moved to 7b0a96a0 (#904 and #886 merged); merged in 203aa3de, signed off. Two conflicts, resolved as their merge notes said:

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 twelve project-command-home* and project-command-seatbelt-* files, project-command-isolation, review, merge-policy, harness-config-lock, the three changelog files and docs-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.

Tests (measured): docs-guardrails and 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/main 9f9533aa merged: #919 deploy sandbox and #928; one auto-merged file, src/review.ts, no conflict) and c63db3b3. origin/main is now 9f9533aa, contained (the earlier rounds' "origin/main is still d2a549f4" was true at their heads, no longer).

  1. Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #919's deploy commands with this PR's sandbox (measured, merged tree, temp HOME, PROJECT_SANDBOX_REQUIRED=1): test/deploy-sandbox.test.ts (it drives the real bubblewrap through a stand-in sudo first on PATH, which records the user and runs the rest; the coordinator harness only runs git on temp fixtures, so no other host tool) gives # tests 8, # pass 8, # fail 0, # skipped 0. The eleven project-command-home* and project-command-seatbelt-* files give # tests 54, # pass 54, # skipped 0. TMPDIR was the session scratchpad (not short); nothing failed on path length.
  2. Deploy commands named: 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 ~/.docker gets "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.
  3. H (the round-12 second review): macOS, above the home. seatbeltProfile now adds (deny file-write* (literal "<d>")) for each directory above the home (not /): the macOS form of the wrapper's above "$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.
  4. Text: "any IPC channel" outside the home (the IPC namespace is shared with the agent user's processes outside the sandbox); only a character device plants nothing, and a writable block device holding the home's filesystem is named next to the second mount (A4).
  5. Body: "five callers" is now "six call sites in five files" (Round 12).

Fail-without (measured): test/project-command-seatbelt-above.test.ts written byte-identically (sha256 285b7f30…) into a fresh worktree at 68873520: # 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-guardrails and the three changelog files: # tests 147, # pass 147, # fail 0, # skipped 0. harness-config-lock, review and merge-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 still d2a549f4, already merged.

  1. realpathSync.native: seatbeltProfileFor resolves the home with the OS's own realpath. Node's JS realpathSync keeps 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).
  2. projectCommand's macOS branch is now tested itself: it takes platform and realpath for tests, as it takes sandboxTool. Had that branch gone back to seatbeltProfile, no test failed before; the new tests would (measured, below).

Fail-without (measured): test/project-command-seatbelt-darwin.test.ts written byte-identically (sha256 81d2d76a…) into a fresh worktree at cc7e84db, 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: projectCommand on this build with the platform injected, nothing spawned):

Home configured as Result
a canonical temp dir built, under sandbox-exec, with the profile that denies writes under it
the same with a trailing / refused: not in normal form (normalHome, before the macOS check)
a symlink to it refused: "isn't its canonical path"
a symlink with a trailing / refused: not in normal form
a missing path refused: "can't be resolved (ENOENT …)"
/var/agent, resolving to /private/var/agent (resolver injected) refused: "isn't its canonical path ("/private/var/agent")"
/Volumes/x/Agent, resolving to lower case (resolver injected) refused: "isn't its canonical path"
a path with /./ refused: not in normal form
/ refused: "whose home is /"

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 through coordinator/shared.ts's sh, via project() in coordinator/base.ts, and deployStep in landing.ts turns a thrown refusal into a failed deploy, never a run): each builds the argv with projectCommand before spawn/spawnSync, with no try around that call that falls back to running the command, so a refusal means nothing is spawned. Whether macOS's real realpath returns the stored case on a case-insensitive APFS volume is a guess here (no macOS in CI); .native is the OS's own realpath(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 still d2a549f4, already merged (measured: merge-base --is-ancestor).

  1. One full list, by design. 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).
    • Added to that list: (B) a hard link to a file an allowed link reaches (measured by the review); the masked ~/.claude* directories and directories credentials.yaml hides 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.
    • "A write anywhere else in the home fails" (changelog) and this body's Intent line now name those masks.
  2. (A) After install: the hard-link find no 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 in docs/auth.md's pointer.
  3. (C) 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).
  4. (F) Code, fail closed: seatbeltProfileFor refuses, on darwin, a home whose realpathSync differs from the configured path or can't be resolved; projectCommand uses 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/var case).
  5. (G) "A command can't make a new hard link" is scoped to Linux; on macOS it's named untested.

Fail-without (measured): test/project-command-seatbelt-home.test.ts written byte-identically (sha256 87aaaae3…) into a fresh worktree at 3ec041b6, built, run: # tests 4, # pass 0, # fail 4 (no seatbeltProfileFor).

After (measured, temp HOME and TMPDIR, PROJECT_SANDBOX_REQUIRED=1, explicit files): project-command-seatbelt-home, project-command-home, project-command-isolation, docs-guardrails and 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: seatbeltProfileFor and 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, and docs/auth.md's list is now the one full list). No code change.

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):

  • B9, a FIFO or socket already in the home: round 8 said "nothing written through it persists". That was false: the second review measured a stand-in agent-user listener outside the sandbox that stored what it received in ~/.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.
  • A2b, hard links: "made before this release" becomes "made outside the sandbox, before this release or since (pnpm's store makes them)". A command can't make a new one: ln gets Invalid cross-device link from the cache tmpfs and Read-only file system in the home (measured by the reviews).
  • A4 added: a second mount of the home elsewhere, such as an admin's bind mount, is a writable way to it.
  • The configured home: the sandbox protects 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).
  • "Any other link … is allowed" is qualified: a link to / 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.md now says exactly what the read-only home leaves open: only links directly in the home and in ~/.config are 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 still d2a549f4, already merged (measured: merge-base --is-ancestor).

What changed:

  1. A1 → A33: a ~/.config outside the home. With a root-owned home whose ~/.config links 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 plant A/git/config (measured at 3d24a65b, in the new test: PLANTED). links() now walks a link outside the home from / by its own path, and link mode starts with own=1 wherever 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).
  2. Refusals name the path at fault: unres takes the path, so a way into /proc says (at '/proc'), not (at '/'), and /dev/stdout says (at '/dev/stdout'), not (at '/dev').
  3. The way test's mknod probe now skips only the device cases (DW to DN), not HA, HS, DV and Q. Under PROJECT_SANDBOX_REQUIRED=1 it fails instead.
  4. Named, not fixed: A5 (pins fixed at start), in What remains, docs/auth.md, the changelog fragment and the code comment; A2's cache side effect in What remains, docs/auth.md and the changelog fragment (not the code comment: corrected after round 8's final check). A13 is the coordinator's separate issue.
  5. Body: the "refused in four cases" line under What this can never do now lists all seven, matching the changelog.

Fail-without (measured): both new files written byte-identically (project-command-home-config-out.test.ts sha256 a499d6b5…, project-command-home-refusal-at.test.ts 591d99f3…) into a fresh worktree at 3d24a65b, built, run with PROJECT_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 plus harness-config-lock and review: # tests 105, # pass 105, # fail 0, # skipped 0. The changelog tests plus docs-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.js is +17,727 (round 7 adds +776), and dist/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 at 76632556, at scripts/setup/credentials.sh:264: take #904's side. #920 (added after round 10, from the round-10 delta check): one hunk in src/review.ts, #920's DECISION_* lines against this PR's HARNESS_CONFIG ones: 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) and 3d24a65b (origin/main d2a549f4 merged; clean).

What changed (src/os/index.ts, LINUX_SANDBOX, link mode only; credential walks unchanged):

  1. A device in a directory the agent can write is refused (A29). It has no bind (bound, it couldn't be opened: nodev), so nothing kept it in place: a command removed it and wrote a file (measured at 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.
  2. A way into /proc or /sys is refused (A30), as add() refuses a credential there. Pinning it bound the host's /proc over the sandbox's own.
  3. Nothing in link mode pins /dev any more (A31, and the cost the round-5 body named): a symlink, a .. or a directory the agent can write under /dev on 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.
  4. The pins and way tests fail when their namespace script fails, with PROJECT_SANDBOX_REQUIRED set or not; only a namespace without bubblewrap, a tmpfs or mknod skips, and not under PROJECT_SANDBOX_REQUIRED=1. Round 5 had added a skip there (principle 9, found by the delta check); this removes it.
  5. Claims: the changelog fragment (the refusal lists, the device lines), 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, sha256 01c0ad5b…, byte-identical) and project-command-home-devproc.test.ts (sha256 34e6d561…) into a fresh worktree at 8bb61504, built it and ran them with PROJECT_SANDBOX_REQUIRED=1: # tests 17, # pass 5, # fail 12. The 12 are every new case:

  • devproc, 7: /proc/1/comm, /proc/self/status, /sys/kernel, /dev/stdout, /dev/fd/0, /dev/../etc/passwd, /dev: each expected: 125, actual: 0 (the command ran);
  • way, 5: DW, DS, DD, DN: expected: 'character special file', actual: 'regular file' (planted); DF: status expected: '125', actual: '0'.
    The 5 that pass are the round-5 cases and the /dev/null case, 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), plus harness-config-lock, review and 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-shrink marker, then const ALWAYS_L3 = [SELF_MODIFICATION, HARNESS_CONFIG];); #904 at scripts/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 under PROJECT_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) and 8bb61504 (CI). origin/main is still 41f6dd42, already merged (measured: merge-base --is-ancestor).

What changed (src/os/index.ts, LINUX_SANDBOX):

  1. The home's way is always pinned: above "$hr/." right after hr=$R. Before, it was pinned only above a ~/.claude* mask or a link into the home (route 1, A24).
  2. A device target's way is pinned before its bind is skipped, except under /dev (route 2, A25). The reviewer's literal fix, above "$x" for every device, would bind /dev for ~/.bash_history -> /dev/null and cost the sandbox its devices (A26, measured), so /dev/* targets are left as before.
  3. Link-mode unres quotes the way (${at@Q}, with at=${cur:-/}), so a newline can't forge a separate project 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 with own=1, so the old text blamed the agent for root's directory. The credential-mode text is unchanged (project-command-isolation.test.ts asserts it, and there it's about a directory under one the agent owns or can write).
  4. Claims: the changelog fragment (both places) and docs/auth.md now say what is pinned, the home's way and a device's included, with /dev named 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.
  5. CI runs the namespace tests, never skips them (the delta check's required change, option (b)):
    • ci.yml, the test shards' bubblewrap step: installs uidmap, gives the runner's user subordinate ids when it has none, and, where kernel.apparmor_restrict_unprivileged_userns is 1, loads an AppArmor profile that lets /usr/bin/unshare (only) create user namespaces. The machine-wide restriction stays on. The step then checks unshare --user --map-root-user --map-auto --mount can mount a tmpfs, so a broken setup fails the step.
    • project-command-home-pins.test.ts and the new project-command-home-way.test.ts now fail rather than skip under PROJECT_SANDBOX_REQUIRED=1, which every shard sets (as the hops test does).
    • This only changes the CI runner. No product check, lint, test or guard is removed or weakened, and the shards' other tests are unaffected (inferred: no other test spawns unshare, measured by grep).

Fail-without (measured): I wrote both new test files byte-identically (sha256 4ff5b259… for project-command-home-way.test.ts, 83b561d0… for project-command-home-unres.test.ts) into a fresh worktree at 26a39041, built it and ran them with PROJECT_SANDBOX_REQUIRED=1. Result: # tests 5, # pass 0, # fail 5:

  • the home's way, writable grandparent: expected '# orig', actual 'PLANTED';
  • the home's way, sticky grandparent: expected '# orig', actual 'PLANTED';
  • the device: expected 'character special file', actual 'regular file';
  • root's unsearchable directory with a newline: Q forged expected '0', actual '1';
  • the plain (no namespace) unresolvable link: the forged-line assertion failed.

After (measured, temp HOME and TMPDIR, PROJECT_SANDBOX_REQUIRED=1, explicit files): project-command-home-way, -home-unres, -home-pins, -home-hops, -home-links, -home and project-command-isolation give # tests 61, # pass 61, # fail 0, # skipped 0. The changelog tests and docs-guardrails give # tests 48, # pass 48. Typecheck is clean, denylist: 1403 files clean, and DCO: 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 head 8bb61504 (ci run 39543735196, measured, not rerun), each shard's step logged user namespace: ok, and they ran, with no # SKIP:

  • shard 1/4, Node 24 (job 119037104199): ✔ 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 …;
  • shard 3/4, Node 24 (job 119037104204): ✔ 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: …;
  • shard 2/4 (jobs 119037104323 and 119037104355): ok 687 - a link whose way holds a newline and an unsearchable directory … and ✔ … the same.

How it was verified

  • Fail-without (measured): I wrote the new tests first and ran them on the unchanged tree (f9422c4e). 9 of 9 failed (# tests 9, # pass 0, # fail 9):
    • harness-config, 3 tests: .worklane/tests.yaml with {}: touches app code (L1, not L3);
    • planting: ~/.bashrc (appended): the sandboxed command wrote it: WROTE .bashrc …;
    • renaming: renamed;
    • caches: ~/.cache kept what a command wrote;
    • symlinked cache: wrote through the symlink into ~/.config;
    • missing home, or /: ran instead of being refused;
    • macOS profile: no deny file-write* rule.
  • After the change (measured, temp HOME and TMPDIR, bubblewrap 0.9.0):
    • 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.
  • Round 4 (head 26a39041, origin/main 41f6dd42 merged in):
    • Fail-without (measured): I wrote test/project-command-home-pins.test.ts byte-identically (sha256 8154e5b6…) into a fresh worktree at 7f3dfc8b, built it and ran it. It runs in unshare --user --map-root-user --map-auto --mount (no sudo), on a tmpfs over /mnt in that private mount namespace. Result: # tests 2, # pass 0, # fail 2.
      • S1: S1 planted:, expected '# root', actual 'PLANTED'.
      • S2: S2 planted:, expected '', actual 'PLANTED'.
    • After (measured, foreground, explicit files): the pins, hops, links, home and isolation tests, plus harness-config-lock, review, merge-policy, coordinator and the three changelog test files, gave # tests 311, # pass 307, # fail 0, # skipped 4.
    • Typecheck is clean, and denylist: 1401 files clean.
    • Where the pins test runs: it needs subordinate ids (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.
      • Corrected in round 5: CI's runners didn't have them. Both shard 3/4 jobs logged # 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.
  • Round 3 (head 7f3dfc8b; origin/main is still a5df2254, already merged):
    • Fail-without (measured): I wrote the final test/project-command-home-hops.test.ts byte-identically (sha256 f71b66b0…) into a fresh worktree at 47b7702b, built it and ran it. Result: # tests 3, # pass 0, # fail 3.
      • The hop bypass: not refused: (expected 125, actual 0).
      • A link hop in root's world-writable /tmp: not refused: (expected 125, actual 0).
      • The skeleton-like links (/usr/share, /etc/passwd, /dev/null, and a dangling link into /): refused (expected 0, actual 125).
    • After (measured): node --import ./dist/test/isolate.js --test on the three changelog test files and on dist/test/project-command-home-hops.test.js, project-command-home-links.test.js, project-command-home.test.js and project-command-isolation.test.js gives # tests 93, # pass 93, # fail 0, # skipped 0.
    • Typecheck is clean, and denylist: 1377 files clean.
    • CI at decb681a (measured, not rerun): both real-user ci-slow jobs and test shard 3/4 failed.
      • The cause: the runner's /usr/local/.ghcup and /usr/share are writable by the agent user and the test user, so round 3's first rule refused them.
      • 7f3dfc8b pins such directories instead of refusing them. Its CI is pending.
  • Round 2 (head 47b7702b, origin/main a5df2254 merged in):
    • Fail-without (measured): I wrote test/project-command-home-links.test.ts first and ran it on b0c9d273 before the change. Result: # tests 10, # pass 1, # fail 9, every failure not refused:. The one that passes checks that links inside the home still run.
    • After (measured): node --import ./dist/test/isolate.js --test on 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.
    • Checks (measured): typecheck is clean, and denylist: 1376 files clean.
    • The real-users probe's command (measured), run locally through projectCommand with a temp home: status 0, both refusals on stderr, and ~/.bashrc unchanged.
  • Checks, round 1 (measured): npm run typecheck is clean, and npm run check:denylist prints denylist: 1322 files clean.
  • Real users (CI): scripts/ci/project-command-real-users.mjs (ci-slow's real-users job, with real sudo -u <agent>) now also checks two things:
    • a project command's write to ~/.bashrc and ~/.gitconfig is refused (Read-only file system). Round 2: this is asserted on stderr, together with ~/.bashrc being unchanged when read as the agent user. A failed >> is reported before any 2>&1 applies, so at b0c9d273 the stdout assertion failed although the sandbox held (CI, measured by the reviewer);
    • a later unsandboxed agent-user process reads neither.
    • ci-slow also runs project-command-home.test.js there with PROJECT_SANDBOX_REQUIRED=1.
  • Full suite, once (fullsuite.sh, temp HOME and TMPDIR; measured): 4,078 tests, 3,992 pass, 18 fail, 55 skipped. The 18 failures:
    • 6 instruction-eval tests in coordinator.test.ts, caused by the lock. Fixed in the test commit above. Afterwards coordinator.test.js gives # tests 146, # pass 142, # fail 0 (measured).
    • 2 fix-forward "real git" tests (fix-forward-coordinator.test.js). They fail the same way on the base commit f9422c4e without this change (measured: # fail 2, both PRs open).
    • 2 isolate.test.js tests. They fail because my scratch TMPDIR is long: …/SingletonSocket is 164 bytes (measured message), which is environmental.
    • 6 testrun helper tests (testrun-env-argv and others): listen EADDRINUSE … docker.sock under that TMPDIR (measured message). Unrelated (inferred: they don't reach projectCommand or review.ts).
    • 1 bench.test.ts sandbox-probe test and 1 add-project.test.ts Auth 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 reaches projectCommand, seatbeltProfile, review.ts or the API-key branch of credentials.sh). I didn't rerun them on the base, because both set a PATH that reaches host tools. CI runs them.
  • Not run locally (left to CI): separate-users, network*, portability and change-files-renames. They set a PATH that reaches /usr/bin and 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-tree of 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 now b0772704, with the same one-line ALWAYS_L3 conflict.

Round 4, at head 26a39041 (measured):

Round 3, at head decb681a (measured): #893 33ef90c3 and #903 1b79f26e are clean. #886 b0772704 has one conflict in src/review.ts: keep the marker line, then [SELF_MODIFICATION, HARNESS_CONFIG]. #904 f63b5373 has one conflict in scripts/setup/credentials.sh: take #904's side.

Package size

Round 6 (measured: npm pack --dry-run --json in a worktree at the new origin/main d2a549f4 and in this branch with it merged, both built):

main d2a549f4 this PR (round 6) delta
size 1,481,905 1,487,165 +5,260
unpackedSize 4,963,126 4,981,017 +17,891

Per 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//sys and /dev refusals and their comments); dist/src/review.js +940, unchanged.

Round 5 (measured: npm pack --dry-run --json in a worktree at 26a39041 and in this branch, both built): 26a39041 gives 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 still 41f6dd42, so against main's round-4 figures:

main 41f6dd42 this PR (round 5) delta
size 1,470,176 1,474,452 +4,276
unpackedSize 4,926,629 4,940,824 +14,195

Per 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 at decb681a; +3,457 / +11,448 at 7f3dfc8b; and +3,848 / +12,882 at 26a39041.

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 b0c9d273 against f9422c4e, 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.

# Attack Result
A1 Write ~/.bashrc through /proc/self/root/<home> and /proc/1/root/<home> held (same read-only mount)
A2a From inside, hard-link ~/.bashrc into the checkout or /tmp, then write the link held (ln fails: cross-mount)
A2b Write through a hard link to ~/.bashrc made before the change (outside the sandbox) broke: the write lands in ~/.bashrc. Residual, below
A3 unshare -Urm, then mount -o remount,bind,rw <home>, umount <home> and umount -l <home> held (locked mounts: permission denied)
A4 The same home reachable through a second bind mount set up outside (an admin's) broke: writable there. Residual, below
A5 A credential credentials.yaml names under ~/.cache held (still masked under the cache tmpfs)
A6 mount -t tmpfs over the home from inside held (no capability)
A7 Get out of a writable cache with .. (~/.cache/../.bashrc) held (read-only)
A8 Plant an npx package in ~/.npm/_npx for later held (gone when the command ends)
A9 ~/.cache a symlink into ~/.config, then write through it held; since round 2 the run is refused; in the tests
A13 Round 2, found by the review: ~/.profile, ~/.ssh, ~/.local, ~/.cache or ~/.npm linked to an agent-writable place outside the home, then write through the link was broken at b0c9d273: writes landed outside, and survived. Now held: refused before anything runs; in the tests
A14 Round 2: a relative link climbing out (../outside/gitconfig), a dangling outward link whose target the command would create, and an outward link in ~/.config held (refused); in the tests
A15 Round 2: over 500 links, to exhaust the check held (refused); in the tests
A16 Round 3, found by the review: ~/.profile -> <outside>/hop/profile with <outside>/hop -> ~/dotfiles (resolves inside the home), then re-aim the hop and write ~/.profile was broken at 47b7702b: the plant landed. Now held: refused, naming the link and the hop; in the tests
A17 Round 3: a hop that is a link in root's world-writable /tmp (writable by the agent, not owned by it) held (refused); in the tests
A18 Round 3: links to what the agent doesn't own, with no link on the way it could re-aim (/usr/share, /etc/passwd, /dev/null, a dangling link into /) allowed, as a skeleton needs; in the tests. At 47b7702b they were refused, which broke CI
A21 Round 4, found by the review (S1): ~/.profile -> W/D/c, W root's and writable by everyone (not sticky), D and c root's; mv W/D away, recreate it, plant c was broken at 7f3dfc8b: ~/.profile read PLANTED outside. Now held: every directory above the target is pinned, and mv fails. ~/.profile reads # root (measured; project-command-home-pins.test.ts)
A22 Round 4 (S2): the same shape with a dangling ~/.npmrc -> W/D/missing was broken at 7f3dfc8b. Now held: refused (125), because a directory on its way is writable (measured; in the tests)
A23 Round 4, measured by the reviewer: another user (another instance's agent user, in its own sandbox) writes a world-writable directory that agent A's home links to open: the cross-user residual. The pins hold only in A's sandboxes. It's no regression, since main followed the link too
A24 Round 5, found by the second review (route 1): home at T/a/home, a the agent's, T root's 0777 or sticky 1777, no ~/.claude*; mv T/a away, recreate the home's path, plant .bashrc was broken at 26a39041 (.bashrc read PLANTED). Now held: mv gets Device or resource busy, .bashrc reads # orig (measured; project-command-home-way.test.ts, and the reviewer's ns.sh rerun: HA, HC, HS)
A25 Round 5 (route 2): ~/.x -> W/D/n, n a character device (a bind of /dev/null), W root's 0777, D root's 0755; mv W/D away, write n as a file was broken at 26a39041 (n became a regular file). Now held: mv gets busy, n is still a character special file (measured; in the tests)
A26 Round 5, my own: the reviewer's literal device fix, above "$x" for every device, on ~/.bash_history -> /dev/null would break every such home: it binds /dev onto itself, which gives /dev nodev (measured: bwrap --dev-bind / / --bind /dev /dev then echo x > /dev/null gets Permission denied). So devices under /dev aren't pinned; the hops test's /dev/null case passes (measured)
A27 Round 5 (route 3): a link's way through ev<newline>project command not run: FORGED LINE/locked, locked root's 0700 was broken at 26a39041: 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)
A28 Round 5: the reviewer's other probes (IH: a hop link back into the home; G: /proc/self/fd, O_PATH, openat, /proc/1/root, xattr, hard links, a nested unshare -Urm remount, caches, crontab), rerun at the new head with the reviewer's ns.sh held, all as before (measured). The $XDG_RUNTIME_DIR stand-in is still writable: #933
A29 Round 6, found by the verification review (inferred there, measured here): ~/.x -> W/n, n a char device (a real mknod c 0 0 in the namespace), W root's 0777; and the agent's own device in a sticky 1777 W; rm W/n, write a file was broken at 8bb61504: n became a regular file in 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)
A30 Round 6, found by the round-5 delta check: ~/.x -> /proc/1/comm, /proc/self/status, /sys/kernel, /dev/stdout, /dev/fd/0 was broken since link mode (at 8bb61504 all 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)
A31 Round 6 (delta check): a non-sticky 0777 /dev/shm (a private tmpfs in the namespace) with root's D/n, mv /dev/shm/D and plant; a device in a writable W under a private /dev/shm; a root file under /dev/shm/D; ~/.x -> /dev/../etc/passwd; ~/.x -> /dev were broken or cost the devices at 8bb61504 (n became a regular file; the rest ran, status 0, measured). Now held: all refused (125) before the run (measured; in the tests)
A32 Round 6, my own: ~/.bash_history -> /dev/null after these refusals allowed: runs, PID 1 is the sandbox's bwrap, /dev/null is writable (measured; project-command-home-devproc.test.ts, and the hops test)
A33 Round 7, found by the round-6 review (A1 there): a root-owned home, ~/.config -> C (root's, outside the home), C/git -> A/git, A the agent's own; plant A/git/config was broken at 3d24a65b (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)
A20 Round 3: a link into root's world-writable directory, as the runner's /usr/local/.ghcup is (~/.ghcup-like -> /var/tmp); write through it, write directly, mv the directory held: runs, every write is Read-only file system, mv fails (measured, adversarial4.mjs). At decb681a this shape was refused, which kept CI red
A19 Round 3: an allowed root-only link, ~/.rt -> /run/user, then write into the agent-owned /run/user/<uid> through it held: Read-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)
A10 Rename, remove, chmod or create in the home (mv ~/.bashrc, rm, chmod 777 ~, ln -s … ~/.cargo, mkdir ~/.config/autostart, touch ~/.hushlogin) held; in the tests
A11 A missing home, or /, with nothing to make read-only held (refused before anything runs); in the tests
A12 A repo's review.yaml drops harness-config from L3, sets its globs to [] or to an unrelated glob, in the engine's repo and in a managed app held (L3 and waits); in the tests

Fixed because of the pass:

  • Round 1: nothing in the code; A9 to A12 became tests.
  • Round 2: A13, which my pass missed and the review found, led to the outward-link and cache-link refusal. A13 to A15 became tests.
  • Round 3: A16 (found by the review) and the CI breakage led to the hop-by-hop walk. A16 to A18 became tests.
  • Round 4: A21 and A22 (S1 and S2, found by the review) led to pinning every directory above the target and refusing dangling links through a writable directory. Both became tests. A23 is named as a residual.
  • Round 5: A24, A25 and A27 (found by the second review) led to pinning the home's way always, pinning above a device target, and quoting the link refusal's way with a truthful reason. All became tests. A26 (mine) kept /dev out of the device pins.
  • Round 6: A29 (the verification review), A30 and A31 (the round-5 delta check) led to refusing a device in a writable directory, a way into /proc or /sys, and any link whose pins would bind /dev. All became tests. My pass hadn't tried /proc.
  • Round 7: A33 (the round-6 review's A1) led to walking a link in a ~/.config outside 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:

  • A2b. A hard link to a home file made outside the sandbox, before this ships (by a pre-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 command) or since (pnpm's store makes them), in a directory on the same filesystem that the agent can write, still lets a later command write that file (corrected in round 10: this said "made before this ships" only). A command can't make a new one (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/*'.
  • R1, links deeper in the home whose way out the agent can change (round 2, revised in round 3). For example, a real ~/.ssh holding config as 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).
    • Such links would come from a command before this release, or from an admin. No command can make a new one, because the home is read-only.
    • The release notes' After install check is now per hop: list the links with find <agent home> -xdev -type l, then inspect each with namei -l <link>. Any entry outside the home that the agent owns or can write makes it unsafe.
    • readlink -m alone would miss A16's shape, so that check was replaced.
    • Suggested follow-up (not built here): a one-time, per-hop scan of every agent home, in doctor, check.sh or the updater, that reports or refuses such links. Once it exists, the optional After install line can go.
    • On macOS the link check isn't implemented, so a link leading out of the home is followed anywhere there (inferred; macOS isn't in CI).
    • The same applies to a link inside what an allowed home link reaches (round 3). An example is /usr/local/.ghcup/bin -> /tmp/x on 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.
  • A23, the cross-user residual (round 4). A link the home holds into a directory that other users can write is pinned and read-only only in this agent user's sandboxes.
    • Any other user who can write that directory still can, including another instance's agent user through its own sandboxed project commands. The reviewer measured it: agent B planted G/env, and agent A read it through ~/.ghcup/env.
    • It's not a regression: main followed every link.
    • Suggested follow-up (filed by you, not built here): give agent users an empty skeleton (useradd -m -k <empty dir>), so a new agent user's home holds no such link.
  • A4. A second mount of the agent's home elsewhere (an admin's bind mount) is a writable path to it. Nothing in setup creates one (inferred).
  • Live agent-user services (from the review; guess on reach). If the agent user has a running per-user service manager or session bus, a command could ask it to start a process outside the sandbox. Setup enables linger only for the coordinator, so this needs something else to have started the agent's user manager. Filed as Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933.
  • Agent-owned paths outside the home are still writable from inside, if an unsandboxed agent-user process reads them as config: /tmp/claude-<uid>/ (Claude Code's temp directory), $XDG_RUNTIME_DIR (the second review planted a systemd/user unit 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

  • Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933 (follow-up, filed): agent-owned paths outside the home ($XDG_RUNTIME_DIR, including systemd --user runtime units, and /tmp/claude-<uid>) and live agent-user services.
  • The residuals: the one full list is 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.
  • Decided in round 6 (the coordinator, under the decision guide: fail closed, a clear message; option (b) of the round-5 delta check; the owner agreed): a link whose way passes a writable directory under /dev, a symlink there (/dev/stdout, /dev/fd/N) or a .. out of a directory there, or whose target under /dev isn't a device, used to get /dev pinned, 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.
  • Project-command sandbox: refuse when run_as.agent_home differs from the agent user's passwd home #954 (filed by the coordinator; K and A13 in the reviews): compare run_as.agent_home with the agent user's passwd home and refuse a mismatch. Not built here.
  • Any IPC channel outside the home (round 9's second review, measured with an abstract socket; the cause corrected in round 14): the sandbox shares the network namespace (abstract sockets, loopback ports) as well as the IPC namespace and the filesystem with the agent user's processes outside it, so a live agent-user process is reachable through any IPC channel outside the home, such as a socket in $XDG_RUNTIME_DIR, the user bus, systemd --user or a loopback port. Sandbox: agent-owned paths outside the home (XDG_RUNTIME_DIR, /tmp/claude-<uid>) and agent-user services #933.
  • macOS links (named since round 1): seatbeltProfile walks no links, so a link leading out of the home is followed and written through there.
  • A5 (round 6's review, measured there; named, not fixed): the walk and its pins are worked out once, when a command starts. A link added to the home while a command runs (only a process outside the sandbox can add one: the home is read-only inside) isn't covered for that command. In docs/auth.md, the changelog fragment and the code comment.
  • A2's side effect (round 6's review, measured there; a cost, not a route): a link to a directory above the home (~/.x -> ..) is bound read-only over the home's own mounts, so for that layout ~/.cache and ~/.npm are read-only too. Named in docs/auth.md and the changelog fragment (not in a code comment).

What this can never do

  • Make the agent's home writable to a project command directly: the home is mounted read-only, the home and every directory above it are pinned (since round 5), and the two caches (and the masks) are empty tmpfs mounts that are gone when the command ends. The ways around it that remain are listed in full in docs/auth.md, "What the read-only home does not stop"; "Still open" below names highlights.
    • Corrected in round 5: at 26a39041 this 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).
    • Corrected in round 2: round 1 said "it can write only the two cache tmpfs mounts", which was false. A symlink in the home that leads outside it could be written through.
    • Now each link directly in the home or in ~/.config (wherever ~/.config leads, since round 7: A33) is walked hop by hop. The run is refused in these cases, the same as the changelog's list:
      1. a hop outside the home could be re-aimed (A16 included);
      2. a hop is the agent's own;
      3. a dangling link passes through a writable directory (S2);
      4. a link ends at a device in a directory the agent can write (under /dev: in or below one) (A29, A31);
      5. a link's way enters /proc or /sys (A30);
      6. a link reaches under /dev anything but a device, or a device through a symlink, a .. or a writable directory there (A31);
      7. a cache is a link at all.
        Also refused, as before: a link reaching / or the command's working directory, and more than 500 links in one directory.
    • Corrected in round 7: round 6's body said "refused in four cases" here while the changelog listed seven (found by the round-6 final check).
    • For an allowed link, every directory above what it reaches is pinned, and the target is bound read-only, in this agent's sandbox. S1 is the case round 3 missed. A device target isn't bound (writing it plants nothing); since round 5 the directories above it are pinned too (A25), except under /dev (A26). Since round 6 a device in a directory the agent can write is refused (A29), and under /dev one in or below any directory there the agent can write, so nothing rests on /dev's writable directories being sticky (A31).
    • Corrected in round 6: at 8bb61504 this 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).
    • Still open: the one full list is docs/auth.md, "What the read-only home does not stop" (since round 11). Highlights only, not a complete list:
  • Unmask anything: every existing mask, self-bind and refusal is kept, and the read-only bind sits under the masks. A home link's pins never cover the sandbox's own /proc or /dev (since round 6): a link whose way enters /proc or /sys is refused, and so is one whose pins would bind /dev.
  • Run a command when the home can't be made read-only (not a directory, or /): it's refused with SANDBOX_UNAVAILABLE, naming the fix.
  • Let a repo's review.yaml take harness-config below L3 or out of the merge floor, or shrink its globs.
  • Touch merge-policy.ts: the floor picks the lock up through LOCKED_CATEGORIES.

Compromised component

Release note

changelog.d/899-agent-home-read-only.md has three sections:

  • Upgrading: what a project sees;
  • After install: optional root checks for hard links made outside the sandbox, before this release or since, with a warning that pnpm's store links are a write route too (A2b; corrected in round 10) and, since round 2, for links deeper in the home (R1). Since round 3 the R1 check is per hop (namei -l);
  • Guardrail changes.

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.ts and test/changelog-fragments*.test.ts give # tests 39, # pass 39.

New dependencies

None.

…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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (independent final check)

Head checked: b0c9d273 (base f9422c4e). Every claim is labelled measured (a command I ran, or CI output I cite), inferred (reasoned, not run) or guess. All local runs used a temp HOME and TMPDIR under my scratch dir. No real sudo was used: this user stood in for the agent user, as the PR's tests do.

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. The real-users CI probe checks the wrong stream, so the job is red and its new checks never ran. (measured) Claude sign-in probe (real users) fails at scripts/ci/project-command-real-users.mjs:83, with actual: '', expected: /Read-only file system/. In echo … >> "$HOME/.bashrc" 2>&1, the >> fails before 2>&1 applies, so bash prints the error to stderr. I reproduced it locally by running the same command string through the head's projectCommand: stdout: "", stderr: "…/.bashrc: Read-only file system …/.gitconfig: Read-only file system", and both files were unchanged. So the sandbox holds, but the probe asserts on stdout. Fix the probe, for example { echo …; } >> f 2>&1, or assert on stderr. Then show the step green. That step also runs project-command-home.test.js, project-command-isolation.test.js and separate-users.test.js with PROJECT_SANDBOX_REQUIRED=1, and none of them ran at this head.

  2. This PR breaks test/separate-users.test.ts:252, on both test shards 2/4 (Node 22 and 24). (measured, CI) The test is regression: a check that runs git, run as the agent user …. It fails with project command not run: the agent user's home '/' is not a directory (other than /) … and 125 !== 0. The test uses { user: 'nobody', home: '/' }, which the new refusal now rejects, as designed. The PR body says separate-users was left to CI, and CI shows it failing. Give the test a real directory as the home, keep its status 0 assertion, and list the edit for the weakening gate.

  3. Outward symlinks in the home keep the plant route open. The PR doesn't name this residual, and two of its claims are false. (measured) I set up a home where ~/.profile, ~/.ssh and ~/.cache were symlinks to agent-writable directories outside the home, then ran a sandboxed command through the head's projectCommand:

    • it appended to the profile;
    • it created ~/.ssh/config;
    • it wrote ~/.cache/persist, which survived the command.

    (A ~/.config symlink is already refused by the existing walk, measured.) Such links would come from a command before this release, or from an admin pointing a cache at a bigger disk. On the false claims:

    • "What this can never do": "it can write only the two cache tmpfs mounts" is false for these targets.
    • The code comment "a symlink isn't followed, what it points to stays read-only" holds only for a target inside the home.

    Required, under principles 1 and 5:

    • (a) fail closed when ~/.cache or ~/.npm is a symlink whose target is outside the home. The wrapper already stats both, so this is cheap.
    • (b) correct both claims;
    • (c) add outward symlinks beside A2b in the residual list, and to the changelog's "After install" check (e.g. find <home> -xdev -type l, then check each target).

    Refusing every outward symlink anywhere in the home at run time would cost installs that use dotfile managers. That part is a product call, listed under For the owner below.

  4. Small claim fix. (measured) Package size, measured at this head with npm pack --dry-run --json against a base build: +1,177 packed and +3,314 unpacked (1,402,614 / 4,702,659 against 1,401,437 / 4,699,345). The body says +1,161 / +3,283, probably measured at an earlier head. This is far under the budget. Update the numbers.

1. Is the class closed?

  • The home's dotfiles and config: ~/.bashrc, ~/.bash_profile, ~/.bash_login, ~/.profile, ~/.gitconfig, ~/.config/git/config, ~/.npmrc, ~/.ssh/config and ~/.local/bin/* are refused as Read-only file system, and a later process with HOME at that home reads nothing planted.
    • Measured: project-command-home.test.js, 6 of 6 pass at the head.
    • ~/.config/* and ~/.claude/* are covered by the same recursive read-only bind; ~/.claude* is also still masked (inferred from the mount order binds, then homes, then masks, plus the passing tests).
    • pip's ~/.config/pip, ~/.pip and ~/.local are refused too (measured: pip install --user fails with [Errno 30] Read-only file system: …/.local).
  • Mount order (inferred from src/os/index.ts:755-767):
    • the ro-bind is on the resolved home (hr=$R), so a symlinked home path is covered too;
    • it comes after the self-binds, which it now covers (EROFS instead of EBUSY under the home, measured in the isolation tests), and before the masks.
    • A home that is missing, not a directory, or / is refused with code 125 (measured).
  • The PR's named residuals:
    • A2b, hard links made before this release: open, as the PR says (inferred; the builder measured it).
    • A4, a second mount of the home: open (inferred).
    • /tmp/claude-<uid>: still writable, since --dev-bind / / shares /tmp (inferred). Whether Claude Code later reads anything there as config or code is a guess.
    • XDG_RUNTIME_DIR: see the new class below.
  • New residuals, in words only:
    • (i) Outward symlinks (required change 3 above).
    • (ii) Live agent-user services. The sandbox shares the network namespace and the /run/user/<uid> socket paths. If the agent user has a running per-user service manager or session bus, a sandboxed command could ask it to start a process outside the sandbox at once; no planting is needed. Setup enables linger only for the coordinator (scripts/setup/instance.sh:31, measured by grep), so this applies only where something else started the agent's user manager, such as an admin's interactive login (guess). The same applies to any localhost or abstract-socket service the agent user runs (guess).
    • (iii) Files in the worktree. The worktree stays writable by design, and an unsandboxed agent-user process may run what is planted there. That is the existing trust level of project code, not this issue's class (inferred).
    • (ii) is worth its own issue.
  • macOS: the profile's (subpath home) only matches if the configured home path is canonical. Seatbelt checks resolved paths, so a home given through /var or /tmp wouldn't match. The existing ~/.claude rules have the same limit (inferred; macOS isn't in CI).

2. No regression for existing installs

Nothing that main closed reopens (inferred, from the diff):

  • every mask, self-bind and refusal is kept and applied after the read-only home;
  • a credential under ~/.cache stays masked under the new tmpfs (the builder's A5, and the isolation tests: 41 of 41 measured passing together with the new file);
  • the harness-config change only adds a category to LOCKED_CATEGORIES and ALWAYS_L3, and a repo's added globs are kept as a union;
  • credentials.sh loses a login shell.

There are new refusals, all fail-closed tightenings the PR lists: a home that is / or missing, and repos that left harness-config out of L3 now get asked. Required change 2 is the one place this breaks a fixture.

3. Edits to existing tests (for the weakening gate)

No assertion was weakened (inferred; each line was read):

  • test/coordinator.test.ts:
    • 2653-2657: evalOnly also filters high-risk: harness-config, a reason that is new with this change. Its presence is pinned at 2782.
    • 2682-2683: comment only.
    • 2722-2730: new assertions (L3, no pr.opened before approval), then an owner approval and two ticks. Every downstream assertion is unchanged.
    • 2782: the expected reasons gain high-risk: harness-config, which is stricter.
  • test/project-command-isolation.test.ts:
    • 299-304 and 327-330: the attack set-up chmod moves outside the sandbox. There is a new assertion that the sandboxed chmod is refused (Read-only file system), and every later assertion is unchanged.
    • 424-439: the rename test now asserts, per path, Read-only file system for the three renames under the home and busy for one outside it (before, any busy anywhere in stderr passed). A third secret is checked. "Contents stay writable" is inverted under the home, which is the intended change, and kept outside it.
  • The fail-without for these edits is builder-measured: per the tooling rule, existing test files aren't edited in a base worktree.

4. The cost (for the owner)

Measured, with a scratch project, a temp agent home and no network:

  • A warm cache doesn't help. npm cache add filled ~/.npm outside the sandbox (content-v2 index-v5). Inside, npm cache ls shows 0 entries and ~/.npm/_cacache doesn't exist.
  • pip install --user fails inside (OSError: [Errno 30] Read-only file system: …/.local). The same command outside succeeds (Successfully installed wl798-1.0).
  • mkdir -p ~/go/pkg/mod fails inside (Read-only file system).
  • An empty project command takes 38 ms with the wrapper, so the overhead is the cold caches, not the sandbox.

Not measured: Playwright browsers hidden, and npm failing on macOS (inferred, as the PR says).

5. Overlaps

Measured with git merge-tree --write-tree:

6. Fail-without and checks

  • Fail-without (measured): I wrote the 2 new test files, byte-identical by sha256, into a fresh worktree at f9422c4e, built it and ran node --import ./dist/test/isolate.js --test dist/test/harness-config-lock.test.js dist/test/project-command-home.test.js. Result: # tests 9, # pass 0, # fail 9. Each failed for the issue's reason:
    • .worklane/tests.yaml with {}: touches app code ('L1' !== 'L3');
    • WROTE .bashrc … WROTE .local/bin/claude;
    • renamed;
    • ~/.cache kept what a command wrote;
    • wrote through the symlink into ~/.config;
    • the missing home ran (0 !== 125);
    • the macOS profile had no rule.
  • At the head (measured): the new files plus project-command-isolation, review and merge-policy give # tests 107, # pass 107. The changelog tests give # tests 39, # pass 39.
  • Typecheck: exit 0 (measured).
  • Denylist: denylist: 1323 files clean (measured).
  • Fragment: changelog.d/899-agent-home-read-only.md is valid (measured by the changelog tests).
  • Size: see required change 4.
  • Protection (measured from the PR metadata): [PROTECTED: owner merges], the needs-owner label, **Risk class:** protected, and real "What this can never do" and "Compromised component" sections are all present. The first "can never do" bullet needs the fix in required change 3.
  • Commits: 6 commits, all signed off, with no attribution trailers (measured). Closes #798 is present. There are no new dependencies.
  • CI at b0c9d273 (measured, not rerun):
    • 16 pass;
    • 3 fail, all caused by this PR: real-users (required change 1), and shard 2/4 on Node 22 and on Node 24 (required change 2);
    • 6 still pending: add-project, egress firewall, wizard e2e and test-run sandbox (all real systemd), and test on Node 22 and 24.

For the owner (a product trade-off, not a required change)

  • (A) This PR: the whole home is read-only, with throwaway caches. It closes the class except for the residuals above (inferred). The cost is cold dependency downloads on every command, failing toolchain writes to the home unless redirected, and npm failing on macOS.
  • (B) Read-only only for dotfiles and config directories. This doesn't close the class (inferred). Much of the rest of a home is code that later processes run:
    • user site-packages (.pth files run at every Python start-up);
    • ~/.local/bin and other PATH entries;
    • Node's ~/.node_modules;
    • Gradle's ~/.gradle/init.d;
    • and the caches themselves (go-build, the Cargo registry, ~/.m2, the npx cache, corepack).
  • (C) Writable cache directories on an allowlist. Hardly any cache directory is safe, because later builds run what caches hold. The workable middle ground is a persistent cache outside the home, per project: tool variables (npm_config_cache, GOMODCACHE, CARGO_HOME, GRADLE_USER_HOME, PIP_CACHE_DIR, PLAYWRIGHT_BROWSERS_PATH) would point there in project commands only, and no process outside the sandbox would use it. That would close the class if nothing unsandboxed ever reads that directory (inferred). Commands of the same project could still poison each other's cache, but that's within the same trust level. It's a follow-up design, not this PR.

…, 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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Round 2: all five points addressed (head 47b7702b)

  1. Real-users probe (scripts/ci/project-command-real-users.mjs): it now asserts that both refusals (.bashrc and .gitconfig: Read-only file system) are on stderr, and that ~/.bashrc is unchanged when read as the agent user. Measured locally: the probe's command, run through projectCommand with a temp home, exits 0, prints both refusals on stderr, and leaves ~/.bashrc unchanged. CI is pending for the real-users step.
  2. test/separate-users.test.ts:272: it now gets a real temp directory as the home (mkdtempSync('/tmp/wl-check-home-'), 0755, removed in finally) instead of /. assert.equal(r.status, 0, …) is unchanged, and no assertion was changed or removed (listed in the body for the weakening gate). I didn't run it locally: it needs real sudo. CI runs it.
  3. Outward symlinks: before mounting, the wrapper now refuses the run in three cases:
    • an entry directly in the home, or directly in ~/.config, is a symlink that resolves outside the home (readlink -m, so relative, .., and dangling links are covered);
    • ~/.cache or ~/.npm is a symlink at all;
    • one of those directories holds more than LINK_CAP (500) symlinks.
    • The refusal names every link at once, with where it leads and sudo -u <agent> rm -- <link>. Links that stay inside the home still run.
    • Fail-without (measured): I wrote test/project-command-home-links.test.ts first and ran it on b0c9d273: # tests 10, # pass 1, # fail 9, every failure not refused:. The one that passes is the inside-the-home case.
    • After (measured): together with the home, isolation, review, merge-policy, coordinator and changelog test files, # tests 306, # pass 302, # fail 0, # skipped 4.
    • Claims corrected:
      • the "can never do" bullet;
      • the code comment in src/os/index.ts, which now says where a link's target stays read-only and where it doesn't;
      • the changelog's Upgrading and Guardrail lines.
    • Residual R1 (an outward link deeper in the home, and any outward link on macOS) is now listed beside A2b, with a find … -type l -exec readlink -m root check in After install. I found no real layout that needs an outward link directly in the home or ~/.config: agent users are service accounts with homes that setup creates (guess). Dotfiles kept inside the home still work (measured).
  4. Package size, re-measured at 47b7702b against origin/main a5df2254: +1,833 packed, +6,011 unpacked. That's dist/src/os/index.js +5,071 and dist/src/review.js +940 (measured). I've also noted your +1,177 / +3,314 at b0c9d273 as the correction of my round-1 numbers.
  5. Merged origin/main a5df2254 with no conflicts. After the merge, typecheck is clean and denylist: 1376 files clean (measured). The overlaps are unchanged at the new head (measured with git merge-tree):

The live agent-user services residual from your verdict is listed in the body as worth its own issue.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 2/2)

Delta check of head 47b7702b against round 1 (b0c9d273). Round-1 verdict: #899 (comment).

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

# Round-1 item State Evidence
1 Real-users probe asserts on stderr; step green; its PROJECT_SANDBOX_REQUIRED=1 tests run Not met The probe edit itself is right: stderr, both files named, ~/.bashrc compared before and after. In CI (Claude sign-in probe (real users), job 118999030105) it fails earlier, at project-command-real-users.mjs:43 (it ran as the agent user, actual: ''). The project command exited 125 with project command not run: '/home/wl-probe-agent/.ghcup' is a symlink to '/usr/local/.ghcup', outside the agent user's home …. So the #798 assertions never ran. The step's node --test … project-command-home.test.js … separate-users.test.js line comes after the probe and didn't run either: no # tests line in the job log. (measured, job log narrowed with grep)
2 separate-users: real temp home, status 0 kept Met The diff changes only the home (mkdtempSync('/tmp/wl-check-home-'), 0755, removed in finally); assert.equal(r.status, 0, r.stderr) is unchanged (measured, git diff b0c9d273 a17b2819). First CI run: ✔ regression: a check that runs git, run as the agent user in the coordinator's checkout, gets git's answer in test shard 1/4 (Node 24), whose job sets PROJECT_SANDBOX_REQUIRED=1 (measured, job 118998901620).
3a Fail closed on outward links and cache links Met for the measured cases, but see required changes 1 and 2 Details below.
3b Correct the false claims Partly met See required change 3.
3c Name the residual; After-install check Met R1 is named in the body, the changelog and the "can never do" section.
4 Size numbers Met Measured npm pack --dry-run --json --ignore-scripts, both built: main a5df2254 1,462,728 / 4,903,731; head 1,464,561 / 4,909,742; delta +1,833 / +6,011. This matches the body and is within PACKAGE_BUDGET (unchanged).

Outward symlinks (measured, head built, temp HOME/TMPDIR, this user as the agent stand-in, bubblewrap)

  • Round-1 probe, rerun: status 125. ~/.profile, ~/.ssh and ~/.cache are each named with their target and the sudo -u shop-agent rm -- fix. The outside profile is still "# own\n", ~/.ssh/config was not created, and the outside cache is []. (~/.cache is named twice, by both messages: cosmetic.)
  • New test file at the head: project-command-home-links.test.js with project-command-home.test.js and PROJECT_SANDBOX_REQUIRED=1 gives # tests 16, # pass 16, # skipped 0. In CI, all 10 link tests ran (ok 765–ok 774) in test shard 3/4 (Node 22), with # fail 0 and # skipped 0 (job 118998901582).
  • Fail-without: I wrote the new file into a fresh worktree at b0c9d273 and built it there. # pass 1, # fail 9; every failure is not refused:. The one that passes is "symlinks that stay inside the home are fine", as expected.
  • Links inside the home still run: test 9 passes (exit 0, writes through them refused). (measured)
  • The 500-link cap fails closed in both places:
    • 501 links in the home: refused (test 10).
    • 501 links in ~/.config: status 125, ran: false, … holds more than 500 symlinks ….
    • Exactly 500 in ~/.config: runs, 532 ms including the sandbox start.
    • A find failure is also a refusal (inferred from links()).
  • The message doesn't leak or forge:
    • A target named …/ghp_AAAA…\nproject command not run: forged prints as one $'…'-quoted word, so no forged line.
    • The engine's redactString turns it into gh*_[REDACTED]. That is the path every project command's output takes (src/coordinator/shared.ts:235, src/queue.ts:134; inferred from the code, measured on the string).
  • Link to a root-owned target: ~/.bashrc -> /etc/hostname is refused (125). That is the same over-reach that broke CI (required change 1).

Required changes

  1. The outward-link refusal breaks project commands for agent users made from a skeleton with an outward link. CI shows it twice.
    • Claude sign-in probe (real users): refused on the runner's ~/.ghcup -> /usr/local/.ghcup (measured, above).
    • add-project (real systemd): auto-config's make test ends FAILED (exit 125) in 0 s (measured, job 118999030053). The same job passed at b0c9d273, and main's ci-slow at a5df2254 is green (measured). That the cause is the same skeleton link is inferred: the log doesn't print the refusal.
    • Why this reaches real installs: setup makes agent users with useradd --create-home (scripts/setup/lib.sh:67, measured by grep), which copies /etc/skel. So this is a real layout, and the body's "I know of no real layout that needs such a link (guess)" is now contradicted. On such a machine, every project command of every agent is refused.
    • Required: make both jobs green with the probe's 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 lines visible in the log, not by changing only the CI fixture.
    • Suggested shape: refuse an outward link only when it can be written through or re-aimed. Check each hop outside the home with the rule walk() already applies to credential paths (refuse a hop the agent owns or can write), and bind the resolved target read-only (--ro-bind t t), or refuse it when it's agent-writable. That passes root-owned targets like /usr/local/.ghcup and also closes item 2.
    • No reopening: main followed every link, so narrowing this round's rule reopens nothing that main closed (inferred).
    • If you keep refusing root-owned targets, that trade-off goes to the owner: availability against a simpler rule.
  2. A direct link that resolves inside the home, but through an agent-writable link outside it, lets a command plant. (measured)
    • Setup: ~/.profile -> <outside>/hop/profile and <outside>/hop -> ~/dotfiles. readlink -m gives ~/dotfiles/profile, inside the home, so the run is allowed.
    • The command runs ln -sfn <outside>/evil <outside>/hop && echo PLANTED > ~/.profile. Exit 0; <outside>/evil now holds profile.
    • Afterwards the home's ~/.profile reads PLANTED\n.
    • The precondition is the same class as R1: a link already in the home, which no sandboxed command can create. But this link is directly in the home, the class the PR says is closed. The After-install command (find … -exec readlink -m) shows it as inside, so the owner's audit misses it too.
    • Required, under principle 1, because the fix is cheap: a per-hop check (item 1) and a test like this one. If you choose not to close it, name it beside R1 and fix the claims in item 3.
  3. Claims to correct. (measured against item 2)
    • The changelog says "Nothing a project command writes in the home outlives it, except through a link deeper down …" and "A symlink that leads somewhere else in the home is fine". Both are false for item 2's chain.
    • The homes code comment in src/os/index.ts says "links leading out of it directly in the home or ~/.config were refused above". That is false for a first hop that leaves the home.
    • The "What this can never do" bullet implies only deeper links remain. Same correction.
    • The body's "empty home / no real layout (guess)" is contradicted by item 1.
    • The overlap section names 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 at f03b59df; its head is now b0772704, with the same one-line conflict (measured, below).
    • docs/auth.md's new sentence makes no claim of completeness and is accurate as written (inferred).

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:

  • Measured: find /usr -xdev -type l -exec readlink -m -- {} + takes 0.17 s warm for 63,320 entries and 10,630 links, with resolution batched. The current loop forks once per link: 500 links measured 532 ms, including the sandbox start.
  • Inferred: at about 2–3 µs per entry, a home with ~1M entries (~/.rustup, ~/go/pkg/mod, nvm and pnpm trees) costs about 2–3 s warm per command, more when cold.
  • Inferred: those trees are full of inward links (node_modules/.bin), so any link cap would refuse them. The cap would have to count only outward links, or the scan would have to be time-bounded.

The cheaper fix by design is a follow-up: a one-time root scan that refuses or reports outward links, in doctor, check.sh or the updater. Once that exists, the optional After-install line can go. Note the scan must check each hop (item 2), not readlink -m alone.

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):

  • The read-only home is what closes 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 class for readers the engine doesn't start: login shells, cron and the agents' own claude.
  • Its cost is cold ~/.cache and ~/.npm on every project command. The builder measured npm ci working through the wrapper; the extra time and network per command were not measured.
  • The follow-up: a persistent per-project cache outside the home, mounted only into that project's sandboxed commands (npm_config_cache and XDG_CACHE_HOME pointed at it), would bring the warmth back. It would not reopen the home route, as long as no unsandboxed agent-user process reads it.
  • What it would allow: one project command could poison the next command of the same project. That is the same trust level as project code itself.

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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 3, past the two-round limit)

Delta check of head 7f3dfc8b against round 2 (47b7702b). Round-2 verdict: #899 (comment).

CI is green, and the round-2 hop bypass is closed. But the pin rule is only half of walk()'s credential rule. add() pins every directory above a credential's final path (above "$r"); link mode pins only the writable directories it passes. A root-owned directory that sits inside a pinned writable one is not pinned, so a sandboxed command can rename it away and plant config that the agent user reads later, outside the sandbox. That is #798's class, and it is measured below. The fix is one line plus tests.

How I measured

  • Stand-ins (measured): the head's real wrapper, run inside unshare --user --map-root-user --map-auto --mount. ns uid 0 (this user) stands in for root, and ns uid 1000 stands in for the agent user.
  • Fixtures (measured): all on a tmpfs at /mnt, in that private mount namespace. Root-owned, world-writable, non-sticky directories can be made there without sudo, and no real path is touched.
  • Scripts: r3-driver.sh, r3-driver2.sh and r3-gen.mjs in the evaluator's scratch dir.

What's inside the sandbox: is every pinned or bound path really read-only?

# Layout (root = ns root; W = root's, 0777, not sticky) Attack from inside Result
S1 ~/.profile -> W/D/c, where D is root's 0755 and c is root's 0644 mv W/D W/D.old && mkdir W/D && echo PLANTED > W/D/c Broke. Exit 0. The command reads PLANTED through ~/.profile, and so does the agent user outside afterwards. W is pinned and W/D/c is ro-bound, but D is neither. A directory that only holds a mountpoint can still be renamed, and the mount moves with it. (measured)
S2 Dangling ~/.npmrc -> W/D/missing, where D is root's 0755 The same rename, then create W/D/missing Broke. The run is allowed because PW=0 (D isn't writable). Afterwards ~/.npmrc reads PLANTED outside. (measured)
S3 ~/.g -> P/G, where P is 0777 and G is root's 0755 holding f mv P/G; write f; write /proc/self/root/.../f; unshare -Urm, then umount, umount -l, mount -o remount,bind,rw, and write Held. mv: Device or resource busy. Writes: Read-only file system. Remount: permission denied. f is unchanged. (measured)
S4 ~/.ghcup -> G, where G is root's 0777 (the runner's shape) Write and create in G, mv G, then nested umount -l and write Held. Read-only file system, and mv gets Permission denied. (measured)
S5 ~/.p -> W/f, where f is root's 0666 (the file branch) Write f, mv W/f Held. Read-only file system, Device or resource busy. (measured)
  • Bind propagation and mount namespaces: a nested user and mount namespace could neither unmount nor remount any bind (S3, S4: locked mounts). /proc/self/root gives the same read-only view (S3). (measured)
  • A second mount of the root filesystem: on this WSL host, /mnt/wslg/distro is mounted ro, so writing the home through it gets Read-only file system (measured). On a host where that second mount is writable, every bind here is bypassed, the home's included. The PR already names this as A4 (inferred).
  • What the fix needs to reach:
    • S1 and S2 need only a link already in the home, as R1 does. But they are links directly in the home, the class the PR says is closed.
    • A link found in D is already covered: the symlink branch pins its parents with above "$cur/$c" (inferred from the code).
    • Only two things are missed: the directories above the final target, and above a dangling link's stop point.

Outside the sandbox: pre-existing trust?

  • The agent user's own processes (inferred): they can already write any world-writable directory, and their own home too. That trust is unchanged.
  • Another user can still plant in this agent's link target (measured, cross-agent):
    • Setup: agent A (ns uid 1000) has ~/.ghcup -> G, with G root's and 0777. Agent B (ns uid 1001) is another instance's agent user with a plain home, and runs a sandboxed project command: mv G/env G/env.old && echo PLANTED > G/env.
    • Result: exit 0, and afterwards A reads PLANTED through ~/.ghcup/env. In A's own sandbox, the same writes get Read-only file system / Permission denied.
    • So "pinned and bound read-only" protects only against this agent's sandbox. A directory that other users can write is planted into by every one of them, including other instances' sandboxed project commands.
    • Not a regression (inferred): main followed every link, and any user could write G there too.
    • But it is the cost of the deviation: the brief's strict rule would refuse such a link. The claims don't say this: the changelog says what it reaches "is read-only inside the sandbox", and Compromised component says "Dashboard, other instances, hub viewer: no new surface".
    • Reach (inferred): each coordinator service has its own private /tmp (docs/design.md:613). A world-writable, non-sticky directory outside /tmp on a real install is rare (guess).

TOCTOU between the check and the mount

  • Inside the sandbox: every decision rests on one lstat of something the agent can't change: a directory's owner, or whether a directory it doesn't own is writable to it (as in Project commands run as the agent user only in a sandbox that hides its credentials; their output is redacted (#713) [PROTECTED: owner merges] #787). Links directly in the home can't be changed from inside, because the home is read-only. (inferred)
  • S1 already plants without any race, so a race through that window adds nothing. I didn't time a separate race. (measured for S1; the rest inferred)
  • After required change 1: every sandbox of this agent pins the same directories, so no other sandbox of the same agent can rename them in the window (inferred). Other users still can: the cross-agent point above.

Requested checks

# Check Result
1a Round-2 hop-bypass probe (r2-edge-probe.mjs C) Refused (125): it names ~/.profile and the hop, evil is [], and ~/.profile still reads # own\n. (measured)
1b Round-1 symlink probe Refused (125): ~/.cache as a link, and ~/.profile and ~/.ssh through an agent-owned directory. Outside, the profile is "# own\n", ~/.ssh/config is absent and the cache is []. (measured)
1c Rest of the edge probe A: a secret-like target prints $'…'-quoted, with no forged line. B: 501 links in ~/.config are refused. D (R1, deeper link) still plants: the named residual. E: ~/.bashrc -> /etc/hostname now runs. (measured)
2 Fail-without: test/project-command-home-hops.test.ts I wrote it into a fresh worktree at 47b7702b (same sha256 as the PR's file) and ran PROJECT_SANDBOX_REQUIRED=1 node --import ./dist/test/isolate.js --test dist/test/project-command-home-hops.test.js. Result: # fail 3. Tests 1 and 2 fail with not refused:. Test 3 fails with expected: 0, actual: 125, and the skeleton links are refused as outward. At the head, with -home-links and -home: # tests 19, # pass 19, # skipped 0. (measured)
3 CI at 7f3dfc8b (runs 39536294558 and 39536294369, both headSha 7f3dfc8b, success) Claude sign-in probe (real users): pass. The log shows agent home: read-only to a project command; nothing planted for a later agent-user process and OK: a project command run as the agent user reads neither…. Its PROJECT_SANDBOX_REQUIRED=1 line ran with ℹ tests 54, ℹ pass 52, ℹ fail 0, ℹ skipped 2. The 2 skipped are the two WORKLANE_TEST_AGENT_USER acceptance tests. add-project (real systemd): pass. runner.full: make test -> passed, and auto-config proposed PR #1. The hops tests ran in test shard 3/4 on Node 22 (ok 743, ok 745) and Node 24 (✔), where the job sets PROJECT_SANDBOX_REQUIRED: 1. Every other check passed. Never rerun. (measured, logs narrowed with grep)
4 Corrected claims, R1, macOS Round 2's false claims are corrected: "except through a link deeper down", "a symlink that leads somewhere else in the home is fine", and the homes comment. R1 and macOS are named in the changelog, the body and the code comment, and the After-install check is now per hop (namei -l). Still wrong after this round: see required change 2. docs/auth.md's new text says what it reaches "is read-only inside the sandbox", which is true for S3 to S5 but not for S1 and S2. (measured against S1, S2 and the cross-agent probe)
5 git merge-tree --write-tree origin/main is now 41f6dd42 (not in the head): clean. #893 33ef90c3: clean. #919 9ee24556: clean. #886 b0772704: one conflict, src/review.ts:444, the same as round 2; keep both (// check-table: never-shrink, then [SELF_MODIFICATION, HARNESS_CONFIG]). #904 f63b5373: one conflict, scripts/setup/credentials.sh:264, the same as round 2; take #904's side. (measured)

Other checks

  • Typecheck: clean. Denylist: 1377 files clean. (measured, at the head)
  • Commits: both new commits are signed off, with no attribution trailers. (measured)
  • Protection: still marked protected, with the needs-owner label. (measured)
  • Weakening gate: the delta changes no existing test file. It adds one test file. (measured, git diff --stat)
  • Adversarial pass: present, with A16 to A20 added. A20 covers S4's shape, but not S1 or S2.
  • Cost (measured, not required): exactly 500 inward links in ~/.config now take 11.9 s per command here, against 532 ms in round 2. Each link is now walked one stat fork per component, and the temp paths are deep (about 13 components). A real home with a few links costs little (inferred).
  • Nit: the hops test's skip text says ci-slow's real-users job runs it. That job runs only -isolation, -home and separate-users; the hops test runs in the ci test shards, which set PROJECT_SANDBOX_REQUIRED=1 (measured).

Required changes

  1. Pin what walk() pins for a credential. In links():
    • For an allowed target, bind every directory above it onto itself (above "$x", as add() does with above "$r") before the --ro-bind "$x" "$x".
    • For a dangling link, either pin above its stop point $P, or refuse it when any directory on its way was writable. Either way, S2's rename fails.
    • Add tests for S1 (target file in a root-owned directory inside a writable, non-sticky one) and S2 (dangling link with the same shape), each with a nested mv.
    • Where the tests can run: a non-sticky, writable directory owned by someone else can't be made without root or a user namespace. They fit the real-users probe, which has sudo: for example sudo install -d -m 0777 /opt/wl-798-w, then a root-owned d/c under it. Or they fit a unit test run under unshare --map-auto, as I did.
    • Principle 1. Cheap.
  2. Correct the claims that S1, S2 and the cross-agent probe contradict:
    • PR body: "bound onto itself, with everything above it, as walk() pins a credential's way" (only the writable directory and its parents are bound, not the directories below it on the way) and "neither re-aiming nor writing through the link is possible (measured above)".
    • Code comments: the hop() comment and the INVARIANT (#798) comment in src/os/index.ts.
    • Changelog: the Guardrail changes sentence "Nothing a project command writes in the home outlives it".
    • Name the cross-user residual: list it beside R1 in the changelog, docs/auth.md, "What this can never do" and Compromised component. A link the home holds into a directory that other users can write is read-only only in this agent's sandbox. Any other user, including another instance's agent user's sandboxed project command, can still write there (measured above).

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 (useradd -m -k <empty dir>), so that no skeleton link exists to refuse. On existing installs, a skeleton link would then refuse every project command until removed, which costs availability but opens nothing. Whether to switch rules is your call; required change 1 applies either way.

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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review: CHANGES NEEDED (head 26a39041)

I tried to break the guardrail, not re-verify the diff. I wrote my attack list before reading the earlier verdicts. None of the three routes below appears in them.

How I measured. I ran the head's real wrapper (projectCommand from the built dist/), as the PR's pins test does: inside unshare --user --map-root-user --map-auto --mount, with ns uid 0 standing in for root and ns uid 1000 for the agent user. Every file was on a tmpfs over /mnt in that private mount namespace, so no real path was touched and no sudo was used. I checked the fixes on an in-memory mutant of the wrapper string; no repo file was edited.

Routes that work

1. Replacing the home path through a writable directory above it (measured).

  • Layout: the home is T/a/home. a is agent-owned, and T is writable by the agent: root's 0777, or a sticky 1777 like /tmp. The home holds no ~/.claude* entry.
  • Attack: a sandboxed command renames T/a away, recreates T/a/home and writes .bashrc in it.
  • Result: exit 0, and the agent's .bashrc, read outside afterwards at the configured home path, says PLANTED. The same happened with a non-sticky and a sticky T. The ro-bind turns the home into a mountpoint, but a directory above a mountpoint can still be renamed.
  • With ~/.claude present: the same attack is refused (Device or resource busy, .bashrc stays # orig). The reason is that the home's way gets pinned only by accident: add() pins above each .claude* mask, and link mode pins above it only when some link's target is in the home. src/os/index.ts:816 (hr=$R) never pins it.
  • Reach (inferred): a home without ~/.claude*, such as an agent whose claude_config_dir is outside the home, under a directory the agent can write two or more levels up. The default /home/<agent> is out of reach, because /home is root's 0755.
  • This makes the first "What this can never do" line false ("Make the agent's home writable to a project command") for that layout. It isn't a regression, since main left the home writable.
  • Fix: add above "$hr/." right after hr=$R (pin the home's way always, as add() does). Measured on the mutant: mv gets Device or resource busy, and .bashrc stays # orig with a non-sticky or a sticky T.

2. S1 with a device as the target (measured).

  • Layout: ~/.x -> W/D/n. W is root's 0777 and not sticky, D is root's 0755, and n is a character device. As a stand-in I used a file with /dev/null bound onto it; stat says character special file.
  • Attack: rename W/D away, recreate it, and write n as a regular file.
  • Result: exit 0, and ~/.x reads PLANTED outside afterwards.
  • Cause: src/os/index.ts:852 skips devices with continue before above "$x", so D is never pinned. W is pinned through hop, and that pin doesn't stop the rename of D.
  • This makes three claims false: "every directory above an allowed target is pinned" in the body, changelog.d/899-agent-home-read-only.md:17 and :41, and docs/auth.md:58.
  • Reach (inferred): this needs a root-made device node inside a root directory inside a writable, non-sticky one. That is rare, but it breaks the class S1 closed.
  • Fix: pin before skipping: { [ "$RT" = "character special file" ] || [ "$RT" = "block special file" ]; } && { above "$x"; continue; }. Measured on the mutant: mv gets Device or resource busy, and ~/.x still reads the device.

3. A refusal message with a forged line, and wrong wording (measured).

  • The forged line: in link mode, unres prints (at ${cur:-/}) unquoted (src/os/index.ts:755). If a directory name on a link's way holds a newline, stderr gets a separate line that starts project command not run: FORGED LINE/locked). That breaks the wrapper's own invariant ("Quoted: the agent names them (no forged lines)").
    • Who can do it (inferred): only whoever names the link's way, so the agent outside the sandbox, an admin or root. A sandboxed command can't, because the home is read-only.
    • Fix: ${cur@Q}. Measured on the mutant: one line, $'…'-quoted.
  • The wording: the same refusal says the way "goes through a directory the agent user owns or can write but cannot search now". In my fixture the unsearchable directory was root's 0700. Link mode starts with own=1, so every unsearchable directory is reported as the agent's own.
    • The run is still refused, so this fails closed. Only the reason given is wrong.
  • Secrets (inferred): nothing leaks. The messages name link paths and their targets, and the agent can readlink those anyway.

Attacks that held

# 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

  1. Pin the home's way always (above "$hr/." after hr=$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.
  2. 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.
  3. 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.
  4. Correct the claims (principle 5): changelog.d/899-agent-home-read-only.md:17 and :41, docs/auth.md:58 and the body's "every directory above … is pinned". Correct "What this can never do" too, unless 1 and 2 land.
  5. File the follow-up issue the body promises: agent-owned paths outside the home ($XDG_RUNTIME_DIR, including systemd --user runtime 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.

@jayden1711

Copy link
Copy Markdown
Owner Author

Delta final check, round 11: CHANGES NEEDED (PR body only; code, docs and changelog at cc7e84db pass)

Head checked: cc7e84db (previous reviewed head 3ec041b6). Worktrees under .worktrees/, with a temp HOME, state and TMPDIR. The round-10 probes ran on a private tmpfs inside unshare --user --map-root-user --map-auto --mount. Nothing touched a real home.

Scope (measured): git diff --stat 3ec041b6 cc7e84db touches four files: changelog.d/899-agent-home-read-only.md, docs/auth.md, src/os/index.ts (+19/-1) and the new test/project-command-seatbelt-home.test.ts. There is one commit. It is signed off and has no attribution trailer.

Round-10 findings A to G

The round-10 probes (gen10.mjs, ns10.sh) were re-pointed at this head's dist/src/os/index.js. The chk step now uses the head's After install command, which has no exclusions. The copies are scratchpad/work/delta-899-r11/gen11.mjs and ns11.sh.

# 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 projectCommand before it returns file and args. 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 existing normalHome refusal 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 by normalHome (:984), so the comparison sees the normal form only (inferred).
  • Linux (inferred, from the diff): seatbeltProfileFor is called only inside if (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/ and README.md for 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.ts into a fresh worktree at 3ec041b6. Its sha256 is 87aaaae3bbcd…2e08, identical to the builder's. I built it with npx tsc and ran node --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 with no 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, changelog and changelog-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.ts locally because it spawns release.mjs with 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.ts is untouched.
  • Typecheck (measured): npm run typecheck is clean.
  • Denylist (measured): denylist: 1420 files clean.
  • Size (measured): npm pack --dry-run --json gives size 1,487,816 and unpackedSize 4,983,100. That is +401 / +1,307 over 3ec041b6 and 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)

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)

  1. 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, realpath or canonical in the Adversarial pass section.)

  2. 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.md instead.

    (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 :1007 went back to seatbeltProfile(...), no test would fail, and grepping test/ finds no sandbox-exec.
    • A test that sets process.platform to darwin and 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.
  • 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/Agent for an on-disk /Users/agent would 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 (libc realpath(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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Delta final check, round 12: CHANGES NEEDED (merge the new main; the head's own code passes)

Head checked: 68873520 (previous reviewed head cc7e84db). I used detached worktrees under .worktrees/ with a temp HOME, state dir and TMPDIR. Nothing touched a real home, and nothing was spawned as another user.

Scope (measured, git diff --stat cc7e84db 68873520): src/os/index.ts (+11/-7) and one new file, test/project-command-seatbelt-darwin.test.ts (+62). There is one commit. It is signed off and has no attribution trailer.

Round-11 findings (issuecomment-6310053568) and the coordinator's additions

# Finding Result at 68873520 Label
1 Round-11 adversarial pass missing from the body Fixed. Round 12 has "Adversarial pass, rounds 11 and 12, on the macOS refusal": 9 home shapes (canonical, trailing /, symlink, symlink plus /, missing, /var to /private/var, wrong case, /./, /), each with its result, plus a paragraph on whether any caller catches the refusal. measured (by reading)
2a Stale "full list" lines in rounds 9 and 10 Fixed. Body lines 170, 173 and 181 each now carry "(superseded by round 11 …)". A grep of the body for full list, in full, complete, four things and fall outside finds no other unmarked completeness claim. measured
2b "What this can never do", first bullet Fixed. It now says the remaining ways "are listed in full in docs/auth.md, 'What the read-only home does not stop'; 'Still open' below names highlights". measured (by reading)
3 realpathSync.native in seatbeltProfileFor Fixed. The default resolver is now realpathSync.native (src/os/index.ts:956). Test 5 swaps fs.realpathSync.native for a lower-casing stand-in and asserts that it is the function called. At cc7e84db that test fails with "can't be resolved (ENOENT … '/Volumes')", which shows the JS resolver was in use. measured
4 A test that drives projectCommand's darwin branch itself Fixed. The new file calls projectCommand(…, { platform: 'darwin' }). It covers a symlinked home, a missing home, a wrong-case home (resolver injected) and a canonical home, where it asserts sandbox-exec -p <seatbeltProfile(home, [])>. measured

Checks

  1. The test-only options can't be reached from production (measured, by grep).
    • At the head, every projectCommand( call passes at most three arguments, so none passes o. The calls are queue.ts:84, worktrees.ts:132,157, tune.ts:47, browser/run.ts:200 and coordinator/shared.ts:214. The CI-only scripts/ci/project-command-real-users.mjs passes two.
    • On the new origin/main 9f9533aa, the set of call sites is the same. Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #919's deploy trigger and verify go through project() → sh() → shared.ts:214, with three arguments.
    • Nothing reads platform or realpath from config, the environment or an agent. As with sandboxTool, they are a fourth positional object that only tests build.
  2. A darwin refusal means nothing runs.
    • The refusal is a throw inside projectCommand, before any spawn or spawnSync. In queue.ts and shared.ts the throw is inside a Promise executor, so the promise rejects and nothing is spawned. The other callers propagate it.
    • On main, Deploy trigger and verify run as sandboxed, redacted project commands (#799) [PROTECTED: owner merges] #919's deployStep (landing.ts) catches the rejection and returns refused: true, which ends in deploy.failed. It is never retried another way.
    • None of these callers has a fallback that runs the command without runAs.
    • Label: inferred, from reading every call site at the head and on main.
  3. Linux behaviour is unchanged.
    • Measured: a probe (scratchpad/work/delta-899-r12/linux-argv.mjs) calls projectCommand with no options on this Linux host in five cases: no runAs, two runAs shapes, a relative home and a missing hide list. It hashes the result. The hash is the same at cc7e84db and 68873520: 74db6431…a185, 26,727 bytes, argv,argv,argv,refused,refused.
    • Inferred: seatbeltProfileFor, the only user of .native, is reached only when platform === 'darwin'.
  4. Fail-without (measured).
    • I wrote test/project-command-seatbelt-darwin.test.ts into a fresh worktree at cc7e84db (cmp-identical to the head's), built it with npm run build and ran node --import ./dist/test/isolate.js --test dist/test/project-command-seatbelt-darwin.test.js.
    • Result: # pass 0, # fail 5. Tests 1 to 3 fail with Missing expected exception, because the platform option was ignored and the Linux branch ran. Test 4 fails with expected: true, actual: false (no sandbox-exec). Test 5 fails as shown in row 3 above.
    • These failures come from the missing behaviour, not from a load error: the file uses a namespace import.
    • At the head, this file plus portability.test.ts: # tests 11, # pass 11, # fail 0.
  5. Weakening gate (measured). No existing test line is changed or removed, and src/merge-policy-weakening.ts is untouched. The code change makes the macOS check stricter, because it now also refuses a wrong-case home.
  6. Single list (measured).
    • docs/auth.md and the changelog fragment don't change in this round, so docs/auth.md:58 is still "the one full list".
    • In the body, What remains, Still open and Compromised component all point to it and name highlights only.
  7. Typecheck and denylist (measured). npm run typecheck is clean, and denylist: 1421 files clean.
  8. Size (measured, npm pack --dry-run --json).
    • The head is 1,487,990 / 4,983,557. That is +174 / +457 over cc7e84db and under the budget of 2,050,000 / 6,400,000 (test/package-files.test.ts:115).
    • These numbers match the body.

Merge-tree (measured, git merge-tree --write-tree)

CI at 68873520 (measured, gh pr checks; nothing rerun)

ci run 39559452585 is a pull_request run for 68873520, created 07:04:18Z and still queued or in progress. ci slow is run 39559452629.

Why the new main matters here

Required changes

  1. Merge origin/main (9f9533aa) into the branch, so CI runs on the merged result.
    • Before pushing, run test/deploy-sandbox.test.ts and this PR's project-command-home* and -seatbelt-* files locally with PROJECT_SANDBOX_REQUIRED=1.
    • Report the result (measured), and update the body's origin/main line.
    • Under rule 12 a reviewer can't build that merged tree, so this is a builder step.
  2. Name deploy commands as covered (inferred cost; measure what you can).
    • In docs/auth.md:58, add the deploy trigger and verify to the list of project commands.
    • In the body's "What breaks" and the changelog's Upgrading section, say that a deploy trigger or verify that writes the agent's home now gets Read-only file system. Examples are a CLI's config or cache under ~/.kube, ~/.config or ~/.docker. Also say what the admin does about it.
    • If you can, measure it with a deploy stand-in that writes ~/.kube/cache/x, in the merged worktree.

Not required

  • The body says "all five callers" and then lists six call sites in five files (worktrees.ts ×2). The count is of files, so the claim holds (measured, by grep).
  • The changelog's macOS line names a symlinked home and a home that can't be resolved. It doesn't name a wrong-case home, which is now refused too. "Canonical path" covers it (inferred).
  • Whether macOS's realpath(3) returns the stored case on case-insensitive APFS is still a guess here, as the body says. No macOS runs in CI.

@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 12: CHANGES NEEDED (one macOS route the one full list doesn't name)

Head checked: 68873520. Linux probes ran against scratch homes on a private tmpfs, inside unshare --user --map-root-user --map-auto --mount, using the wrapper argv that projectCommand builds from this head's dist/src/os/index.js. macOS probes call projectCommand with platform: 'darwin' and injected resolvers; nothing is spawned. No real home was touched.

Scope (measured): git diff --stat cc7e84db 68873520 touches src/os/index.ts (+11/-7) and the new test/project-command-seatbelt-darwin.test.ts. The seatbelt tests pass: project-command-seatbelt-darwin and -seatbelt-home, # tests 9, # pass 9, # fail 0 (measured, temp HOME and TMPDIR).

Attacks

# Goal / claim attacked What I tried Result Label
1 Linux: "the home is read-only" A host tmpfs mounted rw at ~/data before the sandbox starts. Wrote ~/data/x and ~/.bashrc Both got Read-only file system. The host's ~/data stayed empty. bwrap's --ro-bind makes submounts read-only too measured
2 Linux: same A nested unshare -Urm inside the sandbox, then mount -o remount,bind,rw <home> permission denied. The write got Read-only file system measured
3 Linux: same Nested namespace, umount -l <home> to reveal the mount under it Refused (EINVAL: the mount is locked). The write got Read-only file system measured
4 Linux: same Nested namespace, non-recursive mount --bind <home's parent> /tmp/nb (copying the parent without the read-only mount on top) Refused (EINVAL: the parent has locked children) measured
5 Linux: same Nested namespace, mount --rbind <home's parent> /tmp/rb, then umount /tmp/rb/agent The rbind worked. The child umount was refused (locked). The write got Read-only file system measured
6 Linux: same Nested namespace, mount --bind <home> /tmp/ob, then remount the copy rw Both mounts refused. .bashrc was unchanged on the host measured
7 Linux: setuid helpers (su, crontab, at) as a way out /proc/self/status inside NoNewPrivs: 1, CapEff: 0. No setuid helper can raise privileges measured (status); inferred (for each helper)
8 Linux: an inherited fd onto the home ls -l /proc/self/fd inside Only stdin (/dev/null), the two pipes and ls's own fd measured
9 Linux: a working directory inside the home Ran the command with its cwd at ~/repo, then echo X > ./x Read-only file system. The host's ~/repo stayed empty measured
10 Linux: IPC to a live agent-user process readlink /proc/self/ns/ipc, inside and outside Both ipc:[4026532210]: SysV IPC and POSIX message queues are shared with the agent user's processes outside the sandbox. This is a channel like the sockets bullet, not a write to the home. No agent-user process is known to read it measured (the namespace is shared); guess (whether anything is reachable). Minor, below
11 Goal 2: the test seams (platform, realpath, sandboxTool) Grepped every projectCommand call in src/ and scripts/ No caller passes a fourth argument (worktrees.ts:132,157, queue.ts:84, browser/run.ts:200, tune.ts:47, coordinator/shared.ts:214, and the CI script). A job file's runAs is JSON and reaches only the second argument. platform falls back to process.platform, with no env or config read. { realpath: undefined } falls back to the real resolver (probe M3: refused with ENOENT) measured (grep and probe); inferred (no other path)
12 Goal 3: a home that passes seatbeltProfileFor where seatbelt won't match Injected resolvers: a home with regex-special characters (/Users/a(b)[c]+), NFC configured but NFD on disk, a lowercasing resolver, an undefined resolver Special characters are escaped in the regex rules and literal in subpath. NFC/NFD and case mismatches are refused. Undefined uses the real resolver measured (the profile text); inferred (that seatbelt matches it)
13 Goal 3/4: macOS, the home's way. On Linux, round 5 made "the home and every directory above it are always pinned" true (A24). Does anything on macOS pin it? projectCommand with platform: 'darwin' and identity realpath for /Users/Shared/wl/agent, where wl is the agent's own Built. The profile's only write rule is (deny file-write* (subpath "/Users/Shared/wl/agent")). Nothing covers /Users/Shared/wl or /Users/Shared. A command could rename wl away, then build X/agent/.bashrc outside the home and rename X to wl. Neither rename names a path under the home, so it plants a home for a later login shell. This is A24 on macOS. docs/auth.md's one full list doesn't name it measured (the profile has no rule above the home); inferred (that seatbelt checks only the paths a rename names; no macOS here). Finding H
14 Goal 3, side note: hide paths on macOS hide: ['/tmp/creds/key'] (on macOS, /tmp is a symlink to /private/tmp) Built, with (subpath "/tmp/creds/key"): the same non-canonical gap round 11 closed for the home. Main passes hide paths as-is too (origin/main:src/os/index.ts:865), so this is #713's surface, not a regression measured (the profile text); inferred (that the rule would match nothing). Follow-up
15 Goal 4: a completeness claim outside docs/auth.md Grepped the body, the changelog fragment and the docs The body and the changelog name highlights only and point to docs/auth.md. No other doc claims a full list measured (grep)
16 Goal 4: "A device it reaches isn't bound, since writing it plants nothing" (docs/auth.md, changelog) Read True for /dev/null and character devices. It is false for a block device the agent user can write: writing that device writes the home's filesystem around the mount. Such access is a route with or without a link inferred. Minor, below

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 /var; nothing else is needed there.

Required change

  • H. macOS: pin the home's way, or name it. On macOS, nothing pins the directories above the home. A home under a directory the agent user owns or can write can be renamed away and recreated, which is A24, closed on Linux in round 5. docs/auth.md says it is the one full list, and the changelog says "A project command can't write the home directly", but neither names this.
    • By the decision guide (1 and 4), close it in code. Either way works:
      • (a) seatbeltProfile adds (deny file-write* (literal "<d>")) for each directory above the home: the macOS analog of above "$hr/.";
      • (b) seatbeltProfileFor refuses a home with any directory above it that the agent user owns or can write.
    • Add a profile-text or refusal test with an injected resolver. Untested on macOS: say so.
    • If neither is done, add it to docs/auth.md's macOS item.

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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Delta final check, round 13: PASS (CI at the head still running)

Head checked: c63db3b3 (previous reviewed head 68873520). I used detached worktrees under .worktrees/ with a temp HOME, state dir and TMPDIR, and exit-1 recording stand-ins first on PATH for sudo, systemctl, loginctl, launchctl, gh, docker, tailscale, useradd and claude. None of them was called (the call log was never created). Nothing touched a real home, and nothing ran as another user.

Scope (measured, git diff --stat 8d373588 c63db3b3): src/os/index.ts (+8/-1), the new test/project-command-seatbelt-above.test.ts (+33), docs/auth.md and the changelog fragment. Both new commits are signed off, and neither has an attribution trailer.

1. The merge commit (measured)

  • 8d373588 has parents 68873520 and 9f9533aa, and its tree is 41f15583….
  • git merge-tree --write-tree 68873520 9f9533aa gives 41f1558340d87a670c8d18694a4dc53cbee63ed5, the same tree. The merge has no hand edits.
  • origin/main is still 9f9533aa, and it is an ancestor of the head.

2. #919's deploy sandbox on the merged tree (measured)

Each run used explicit files and PROJECT_SANDBOX_REQUIRED=1.

  • I read the PATH of each test first. deploy-sandbox puts its own stand-in sudo first on PATH, which drops -n -u <user> -- and runs the rest as this user. Its harness runs only git, on temp fixtures. project-command-home.test.ts sets /usr/bin:/bin only for a bash that sources the temp home's files and runs git config. The other tests run the wrapper's argv after sudo -n -u <agent> --, so they never call sudo.
  • test/deploy-sandbox.test.ts: # tests 8, # pass 8, # fail 0, # skipped 0.
  • The twelve project-command-home* and project-command-seatbelt-* files, including the new one: # tests 56, # pass 56, # skipped 0.
  • project-command-isolation, docs-guardrails and the three changelog test files: # tests 83, # pass 83.
  • Together that is 147 of 147, the builder's number.

A refusal is a failed deploy, never an unsandboxed run.

  • Measured: test 2 (a swappable .config link) and test 3 (a runAs without its hide list) both end in deploy.failed, with nothing run and no deploy.verified. Test 3 also starts nothing as anyone.
  • Inferred, from reading the merged tree: deployStep (src/coordinator/landing.ts:432-441) turns a throw into refused: true, and a SANDBOX_UNAVAILABLE exit with "project command not run" into refused: true too. deploy() returns failed(...) on either result. Nothing retries the step without runAs.

The cost (measured). I copied the builder's deploy-standin.mjs and pointed it at this worktree. The trigger runs through projectCommand's argv after sudo and tries to write the home:

  • writes to ~/.kube/cache, ~/.config/deploy-tool and ~/.docker all fail with mkdir: cannot create directory …: Read-only file system;
  • none of the three exists in the home afterwards.

This cost is named in docs/auth.md:58's list of project commands ("a deploy's trigger and verify"), in the changelog's Upgrading section (with KUBECACHEDIR and DOCKER_CONFIG, pointing to #912, which is open) and in the body's What breaks.

3. macOS: the directories above the home (finding H)

The profile text (measured). I adapted darwin12.mjs's cases to this head (delta-899-r13/darwin13.mjs; nothing spawned, resolvers injected). The H case, home /Users/Shared/wl/agent, now builds:

(deny file-write* (subpath "/Users/Shared/wl/agent"))
(deny file-write* (literal "/Users/Shared/wl") (literal "/Users/Shared") (literal "/Users"))
  • / is never named.
  • A home directly under / (/agent) gets no literal rule.
  • A deep home with a space (/Volumes/disk 2/a/b/c/agent) gets all five directories above it.
  • A home containing " is still refused before any profile is built, so no quoting can break out of (literal "…").
  • The NFC/NFD and special-character cases from round 12 behave as before: NFD is refused, and special characters are escaped in the regex rules.

Writes the command still needs. Take a working directory above the home, /Users/Shared/wl. Only that directory's own path matches a rule. /Users/Shared/wl/file and /Users/Shared/wl/work/file match neither a literal nor a subpath rule (measured, on the profile text). By seatbelt's rule semantics, a literal rule matches only the operations that name that path. Creating, writing, renaming or removing a file inside the directory names the child's path, so those stay allowed. Renaming, removing, re-creating or chmod-ing the directory itself is denied (inferred; there's no macOS here). The default layout keeps worktrees in the coordinator's state directory, never above the agent home (inferred, as in earlier rounds).

Why every directory above is covered (inferred). seatbeltProfileFor refuses a home that isn't its own canonical path. So no component of the path is a symlink, and each directory above the home is canonical as well. The literal rules name real paths.

Untested on a real macOS: the code comment, docs/auth.md's macOS bullet and the body's Still open all say so. Not blocking, and labelled a guess: on a case-insensitive APFS, whether seatbelt matches a rename's source through the stored-case path (which would make the literal rules hold) or the case the caller typed. It belongs with the macOS measurement that the docs already name as missing.

Fail-without (measured).

  • I wrote test/project-command-seatbelt-above.test.ts into a fresh worktree at 68873520. Its sha256 285b7f30…06bd is the same as the head's file.
  • There, npm run build then node --import ./dist/test/isolate.js --test dist/test/project-command-seatbelt-above.test.js gives # tests 2, # pass 1, # fail 1. Test 1 fails with /Volumes/x/wl isn't pinned: (version 1)…, which is the route. Test 2, a home directly under /, is the control and passes.
  • At the head, both pass.

Linux is unchanged by round 13 (measured). I ran round 12's linux-argv.mjs probe at 68873520 and at c63db3b3. Both give the same hash, 9de54008…1eed, and argv,argv,argv,refused,refused.

4. The single full list (measured, by grep and reading)

  • docs/auth.md:58 ("This is the one full list") is still the only completeness claim. The changelog fragment (line 51) and the body (What remains, What this can never do → Still open, Compromised component) point to it and name highlights only. A grep of docs/ and changelog.d/ for full list, in full, one full and complete list finds no other.
  • Each item the brief names is in it:
    • Deploy: the project-command list names "a deploy's trigger and verify".
    • IPC: "any IPC channel outside the home too, since the sandbox shares the IPC namespace …".
    • Block device: next to the second mount (A4), "a block device the agent user can write that holds the home's filesystem". The device sentence above the list now limits "plants nothing" to a character device.
    • Untested macOS: the last bullet says the above-home rule "is untested on a real macOS", and that until it is measured there, a home under an agent-owned directory may still be replaceable.

5. Other checks

  • Weakening gate (measured). c63db3b3 adds one test file and changes or removes no existing test line. The merge 8d373588 is exactly merge-tree's result, so it changes no test line of its own. src/merge-policy-weakening.ts is untouched. The profile change adds deny rules only, so it is a tightening.
  • Typecheck (measured): npm run typecheck is clean.
  • Denylist (measured): denylist: 1428 files clean.
  • Size (measured, npm pack --dry-run --json): 1,490,683 / 4,992,294, as in the body. That is under the budget of 2,050,000 / 6,400,000 (test/package-files.test.ts:115).

Merge-tree (measured, git merge-tree --write-tree at c63db3b3)

CI at c63db3b3 (measured, gh pr checks; nothing rerun)

ci run 39561420348 is a pull_request run for c63db3b3 against main 9f9533aa. It was created at 07:21:38Z and is still queued or running.

  • Required checks (main's ruleset):
    • Pass: dco, denylist, new-deps, scan (×2).
    • Pending: agentshield, push-gate, test (ubuntu-latest, 22) and test (ubuntu-latest, 24). All eight test shards are pending.
  • Other checks:
    • Pass: bootstrap on macOS, Ubuntu and windows-wsl, desktop, scope, and the Claude sign-in probe.
    • Pending: add-project, egress firewall, test-run sandbox and wizard end to end (all real systemd).

Required changes

None. Merge only after the pending required checks pass.

Not required (text only)

@jayden1711

Copy link
Copy Markdown
Owner Author

Second adversarial review, round 13: head c63db3b3

Verdict: CHANGES NEEDED (text only). The code held on Linux, and the deploy path held. No route reopens anything main had closed. But the "one full list" in docs/auth.md is not full: two filed routes from a project command to the agent's home are missing (#958, #926), and the deploy (cwd: the main checkout) now reaches both. The list also gives the wrong cause for the measured abstract-socket route. On macOS, mounts aren't named.

I wrote my attack list before reading the earlier verdicts. Setup (measured): unshare --user --map-root-user --map-auto --mount --net, a private tmpfs at /mnt, ns uid 1000 as the agent user, and wrapper argv from this head's projectCommand. Every write stayed inside the namespace's tmpfs or my scratch dir. No sudo, no real home. Recording stand-ins were first on PATH, and none was called.

Attacks

# 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)

  1. 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.
  2. 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.
  3. 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 no file-mount rule, and there is no mount namespace.
  4. 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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (body only): delta final check, round 14, head a5277e54

The head's text is right. docs/auth.md and the changelog fix every item from the round-13 second review (issuecomment-6310910989), and CI is green. Two lines in the PR body still carry the old IPC wording. Fixing them is an edit to the body only: no new commit, so the head stays a5277e54.

1. The delta has no code and no tests (measured)

git diff --name-only c63db3b3 a5277e54 lists only changelog.d/899-agent-home-read-only.md and docs/auth.md. One commit, signed off, with no attribution trailer. No test line changed, so the weakening gate has nothing to judge. There is no fail-without because the change is text only.

2. Round 13's findings

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, brand and denylist ran 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)

Required changes (PR body only; principle 5)

  1. 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.md does. Round 13's item 4 named this section explicitly.
  2. 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>
@jayden1711

Copy link
Copy Markdown
Owner Author

Second review (verification-scoped)

Verdict: PASS on a5277e54. The scope was narrowed after a classifier stop, per the owner's rule of 2026-10-21. Three things were in it:

  • re-run the documented attacks (the body's tables, and the r9, r10, r12 and r13 reviewers' scripts);
  • re-run every fail-without the body names, at its stated base;
  • check the claims in docs/auth.md, the changelog fragment and the body against the code.

I built no new payloads. Every documented route that was closed still holds. Every route that still works is in docs/auth.md's one full list. No claim I checked is false.

Before merge (required step for the builder, not a finding on this head): origin/main has moved to 7b0a96a0, with #904 and #886 merged. So the body's "origin/main is still 9f9533aa, contained" no longer holds. git merge-tree --write-tree origin/main a5277e54 gives two conflicts, exactly as the body predicts (measured):

The merged tree then needs a delta check and CI.

Setup (measured)

  • Linux probes: unshare --user --map-root-user --map-auto --mount (--net added for r13), with a private tmpfs at /mnt. Namespace uid 1000 is the agent user. The wrapper argv comes from this head's projectCommand.
  • Tests: temp HOME, TMPDIR and state dirs, PROJECT_SANDBOX_REQUIRED=1, recording stand-ins first on PATH. No stand-in was called.
  • What I adapted: the reviewers' gen*.mjs and darwin*.mjs were copied with only the import path changed to this build. Their ns*.sh scripts ran unchanged.
  • What I didn't run: the author's adversarial3.mjs and adversarial4.mjs (A19, A20). They write to the real /run/user/<uid> and /var/tmp. The same shape is covered on scratch fixtures by r10's HLT and by the hops test.
  • Writes outside scratch: r12's ns12.sh nested cases run mkdir -p /tmp/nb /tmp/rb /tmp/ob in the real /tmp, because only /mnt is private. Note this for future reruns.

Attacks re-run

# Source Result Label
A1, A2a, A3, A5–A8 author's adversarial.mjs Held: /proc/*/root, a new hard link (EXDEV), nested remount or umount, a mask under ~/.cache, mount with no capability, .. out of a cache, and the npx cache, which doesn't persist measured
A2b, A4 same Broke: a hard link made outside, and a second mount. Both are named in docs/auth.md measured
A9–A33 "in the tests" Covered by the named test files at this head: 116 of 116 pass, 0 skipped (project-command-home* ×10, -seatbelt-* ×3, -isolation, harness-config-lock, review). Docs, changelog and merge-policy tests: 96 of 96 measured
r9 ns2 HW, IP, CC Held: home writes get Read-only file system; in-home pins hold; caches start empty and are thrown away measured
r9 ns2 R1, HL-pre, FF, A4, AB Open, all named: a deeper link was written; a pre-made hard link was written (new ones get EXDEV); a FIFO reader outside kept what was written (mkfifo and bind in the home get RO); the alt mount; an abstract socket measured
r9 ns3 sl, cw, h40, h41, us /, cwd, 41 links and an unsearchable directory each refused (125). 40 links ran. h41's text still says "owns or can write" for root's directory (named, not done, round 10; it fails closed) measured
r10 ns10 HW, MK Held (MK: .claude is a throwaway tmpfs, nothing persists, .local is RO) measured
r10 ns10 CH, HLT, RO Open, all named: hard links into the real ~/.cache, to an allowed link's target (via the link and direct write both RO, other name written), and in root's directory measured
r12 ns12 SUB, NEST, MISC Held: a host submount in the home is RO inside. Nested remount, umount, bind, rbind and copy-remount all fail, and the home is unchanged. NoNewPrivs: 1, CapEff: 0. A cwd in the home is RO measured
r13 ns13 B10 The checkout's .claude/settings.json hook was written and, run outside, planted .bashrc. Named (#958). A direct .bashrc write gets RO measured
r13 ns13 NS Shared net and IPC namespaces. A child in a new IPC namespace still connects; one in a new net namespace gets Connection refused. This matches the corrected IPC bullet measured
darwin12, 13 M1–M5, H1–H6 Same results as recorded: literal denies above the home, none for /agent, SBPL characters inert, no mount rule, NFC/NFD refused, an undefined resolver uses the real one. M2 (hide paths not canonicalised) is #713's surface, as r12 said measured (profile text); guess (enforcement on a real Mac)

Fail-without (measured)

Each test was written byte-identically as a new file *-v14.test.ts (sha256 matches this head) into a fresh worktree at the stated base, then built and run.

Base File(s) Result Body says
f9422c4e project-command-home, harness-config-lock 9 tests, 0 pass, 9 fail 9/9
b0c9d273 -home-links 10 tests, 1 pass, 9 fail same
47b7702b -home-hops 2 of 2 fail; I skipped the third case, which links in the real /tmp 3/3
7f3dfc8b -home-pins 2 of 2 fail (S1 and S2 PLANTED) 2/2
26a39041 -home-way (final), -home-unres 10 of 10 fail 5/5, on the round-5 file; the final file adds round 6's cases
8bb61504 -home-way (final), -home-devproc 17 tests, 5 pass, 12 fail same
3d24a65b -home-config-out, -home-refusal-at 6 tests, 1 pass, 5 fail same
3ec041b6 -seatbelt-home 4 of 4 fail same
cc7e84db -seatbelt-darwin 5 of 5 fail same
68873520 -seatbelt-above 2 tests, 1 pass, 1 fail same

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 is bound with --ro-bind. ~/.cache and ~/.npm get a --tmpfs only over a real directory (src/os/index.ts:914).
  • The mount order is binds, homes, lbinds, then hide (:917). So the masks stay on top: "Unmask anything" holds.
  • Masked directories get a tmpfs, files get /dev/null (:824).

The home's way is always pinned; a home that isn't a directory, or is /, is refused (:833-834, :902).

The seven refusal cases, and the others (each at the line named):

  • re-aimable hop: :810
  • the agent's own path: :876
  • dangling link through a writable directory: :882
  • a device in a writable directory: :891-893
  • /proc or /sys: :806
  • under /dev: a symlink :807, a writable directory :808, a .. :788, not a device :879
  • a cache that is a link: :867
  • also: more than 500 links :869, more than 40 hops :810, unsearchable :798, / :896, cwd :895

An allowed link: every directory above its target is pinned and the target is bound RO (:898). A device isn't bound, and outside /dev the directories above it are pinned (:894). A ~/.config that leads outside the home is walked from / (:875).

Residuals in docs/auth.md:

macOS:

  • The profile denies writes under the home with a subpath rule, and each directory above it with a literal rule. It has no mount rule (:936-951).
  • seatbeltProfileFor checks realpathSync.native and refuses a mismatch (:963-971). projectCommand's darwin branch uses it (:1017-1019).

harness-config lock (src/review.ts):

  • the category and its globs: :322-323
  • in LOCKED_CATEGORIES: :434-437
  • in ALWAYS_L3: :474
  • a repo's globs are kept beside the built-in ones (a union): :580
  • raised to L3: :634

merge-policy.ts isn't in the diff.

credentials.sh runs sudo -H -u "$agent" claude auth status --json from / (scripts/setup/credentials.sh:260), and claude_login_ok does the same (scripts/setup/lib.sh:721). No sudo -i is left in credentials.sh.

Statements of completeness: the changelog says "lists them all" and the body says "listed in full". Each points to the docs/auth.md list, and every residual measured above appears in that list.

Optional (not blocking)

  • The changelog fragment's Upgrading bullet "On macOS nothing in the home is writable, ~/.npm included" isn't marked untested. The same fragment's Guardrail section and docs/auth.md say the profile is untested on a real Mac. Adding "(untested on a real Mac)" there would match them.
  • Note only: project-command-home-hops.test.ts creates a symlink in the real /tmp and removes it in finally. It needs a world-writable directory the test user doesn't own, so a scratch dir wouldn't do. It's harmless in CI and nothing was left behind here (measured). Local reviewers who must keep writes in scratch can skip that one case.

@jayden1711

Copy link
Copy Markdown
Owner Author

Second review (verification-scoped)

Verdict: PASS on 203aa3de. This head merges origin/main 7b0a96a0 into a5277e54, which passed in issuecomment-6311772089. I re-ran the same scope here. I built no new payloads.

The merge (measured)

Tests (measured)

Run with temp HOME, TMPDIR and state, PROJECT_SANDBOX_REQUIRED=1, and recording stand-ins first on PATH (none was called). The files:

  • the 15 named files: project-command-home* ×10, -seatbelt-* ×3, -isolation, harness-config-lock, review;
  • plus deploy-sandbox.

Result: # tests 123, # pass 123, # fail 0, # skipped 0. I skipped one hops case by pattern because it links in the real /tmp; it passed at a5277e54.

Probes re-run (measured)

These were run as written against this build, all on the private /mnt. For r12 I used a copy of ns12.sh with its NEST block left out (that block writes to the shared /tmp), so SUB and MISC only. Every result is identical to a5277e54:

  • r9:
    • Held: HW, IP, CC.
    • Open, all named in docs/auth.md: R1, a pre-made hard link, FIFO, A4, abstract socket. New hard links still get EXDEV.
    • ns3: /, the cwd, 41 links and an unsearchable directory are refused (125). 40 links runs.
  • r10:
    • Held: HW. MK: .claude is a throwaway tmpfs; .local is read-only.
    • Open, all named: CH, HLT (the other name is written; the link and direct writes are read-only), RO.
  • r12: SUB (a host submount is read-only inside) and MISC (a cwd in the home is read-only) held.
  • r13:
  • darwin12/13: the same profile text and the same refusals.

Nothing was left in /tmp.

Required changes: none.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 15 delta, head 203aa3de): one body line only

Delta final check of 203aa3de (it merges origin/main 7b0a96a0, which brings in #904 and #886, into a5277e54) against the previously reviewed head a5277e54 (issuecomment-6311636738). The code and the merge are clean. One sentence in the body is now false.

1. The merge

2. The body (read through the raw API at head 203aa3de, 0 control characters)

  • "What remains" (body line 478): "Any IPC channel outside the home ... the sandbox shares the network namespace (abstract sockets, loopback ports) as well as the IPC namespace and the filesystem ..." is present (measured). It matches the code: PROJECT_SANDBOX_ARGS (src/os/index.ts:735) unshares user and pid only, with no --unshare-net and no --unshare-ipc (measured).
  • Still open (line 503): "(the sandbox shares the network and IPC namespaces)" is present (measured). It matches the changelog fragment's line 54 and docs/auth.md:63 (measured).
  • Risk class (line 3): it no longer names credentials.sh (measured).
  • Issue item 1 (line 64): a round-15 note marks the rest as history (measured).
  • Not updated, line 106 (measured), under "Existing installs: nothing that was closed reopens": "credentials.sh now runs one fewer login shell." This PR no longer changes credentials.sh, so the claim is now false for this PR. setup: an Anthropic API key is a first-class agent login (add-project, credentials.sh api-key, doctor) [PROTECTED: owner merges] #904 did that. See required change 1.

3. Changelog

changelog.d/899-agent-home-read-only.md no longer mentions credentials.sh (measured: grep finds none). Its only change against a5277e54 is that one removed line (measured).

4. Checks at the head

  • Docs, changelog and project-command tests (measured). I read each test's PATH and stand-ins first. These run bwrap as the current user on temp homes; deploy-sandbox puts its own recording sudo first on PATH. Files: project-command-home and its 8 siblings, the 3 project-command-seatbelt-* files, project-command-isolation, deploy-sandbox, docs-guardrails, changelog-fragments-selfcheck, changelog-fragments and changelog. Result: # tests 147, # pass 147, # fail 0, # skipped 0.
  • npm run typecheck: exit 0 (measured).
  • npm run check:denylist: denylist: 1444 files clean (measured).
  • Commit and dependencies (measured). The merge commit is signed off and has no attribution trailer. The dco and new-deps CI jobs pass.
  • CI at 203aa3de (measured, gh pr checks, runs 39570721063 and 39570721157, both headSha 203aa3de):
    • 21 jobs pass.
    • 5 are still pending: test shard 1/4 (ubuntu-latest, 22), egress firewall (real systemd), add-project (real systemd), test-run sandbox (real systemd) and wizard end to end (real systemd).
    • Not rerun.

Merge-tree (measured, git merge-tree --write-tree of 203aa3de against each head)

Required changes

  1. Body only. In "Existing installs: nothing that was closed reopens", correct "credentials.sh now runs one fewer login shell." For example, say that this PR no longer changes credentials.sh, and that the login-shell route it had closed there 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's claude_api_key_login_ok (sudo -H -u and a non-login bash -c), as the round-15 note says. Principle 5: a false claim is corrected. No code change. I'd accept a text-only delta for it.

Optional, not required: src/review.ts:325 reads HARNESS_CONFIG_GLOBS =[, with no space before [. tableSpan and registrationsOf read it correctly (measured above). It is cosmetic only.

@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: PASS (round 15 delta follow-up, head 203aa3de)

This follows up on issuecomment-6311926077. The head is still 203aa3de.

  • Body (measured, raw API at headSha 203aa3de, 0 control characters). Line 106 under "Existing installs" now reads: "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's claude_api_key_login_ok, which runs as the agent through sudo -H -u and a non-login bash -c ...". That matches the code I read at the head (lib.sh:64, :88-92, :795; credentials.sh:284). The one required change is done. Nothing else in the body changed that I checked: the Risk class line, the IPC lines (478 and 503) and the round-15 notes all read as before.
  • CI at 203aa3de (measured, gh pr checks):
    • test shard 1/4 (ubuntu-latest, 22) and egress firewall (real systemd) now pass, as do all the other test shards and fast jobs (23 pass, 0 fail).
    • 4 jobs are still pending:
      • add-project (real systemd)
      • test-run sandbox (real systemd)
      • wizard end to end (real systemd)
      • new-deps, re-triggered by the body edit (run 39572236266).
    • Not rerun.

This PASS covers the review and holds only if those 4 pending jobs finish green. The merge waits on them. No required changes.

@jayden1711
jayden1711 merged commit b3176d3 into main Oct 21, 2026
29 checks passed
@jayden1711
jayden1711 deleted the issue-798 branch October 21, 2026 15:17
jayden1711 added a commit that referenced this pull request Oct 21, 2026
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>
jayden1711 added a commit that referenced this pull request Oct 21, 2026
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.
jayden1711 added a commit that referenced this pull request Oct 21, 2026
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

#899, #976 and #902 merged on main; no conflicts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant