fix(daemon): keep the confined session tier inside the sandbox git inventory - #1783
Conversation
…ventory A sandboxed session on a managed pool could not start: preparation reported "git checkout is not in the permitted inventory" and the turn never ran. On a pool member every workspace Git call crosses the shim's exec channel and is judged there against a small, deliberate subcommand list -- the process that spawns git is the only one whose check is a control. `checkout` is not on that list. The worktree tier never needed it (`worktree add -b` creates the branch and `worktree` is admitted); the per-session clone tier put the clone on its branch with `checkout --no-track -b`, which the sandbox refuses. A self-hosted daemon runs git locally and never meets the list, so every verification there passed. Express the operation out of subcommands already admitted rather than widening the list: `branch --no-track` at the target, `symbolic-ref HEAD` onto it, then `reset --hard` to materialize the tree. Same result, and the materializing step keeps the clone env so a blobless clone may still fetch lazily. Retirement's review-snapshot probe had the same shape of gap -- `for-each-ref` is not admitted either, and its refusal was swallowed by the probe's own catch, so a dirty review clone on a pool member was retained forever instead of removed; it now reads the head ref with `show-ref --verify`. The rest of the confined path was enumerated against the inventory and is clean: clone, config, check-ref-format, remote, ls-remote, fetch, update-ref, rev-parse, rev-list, show-ref, symbolic-ref, branch, reset, clean, status, log, diff. Tests run the real exec handler behind the real remote runner, so the inventory is enforced in the loop rather than stubbed: a confined session is prepared, reviewed and retired across that channel, and every subcommand it issues is asserted to be in the inventory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approved — no blocking findings.
The confined clone path now stays within the shim’s existing Git inventory without weakening that boundary. The branch --no-track → symbolic-ref HEAD → reset --hard sequence preserves the prior branch target, absent upstream, and materialization behavior, including the lazy-fetch environment needed by partial clones. Replacing the retirement probe with show-ref --verify is also faithful because every successful review fetch writes and verifies that exact head ref; failures remain conservative.
I verified the trusted synthetic merge parents match the supplied base/head, traced prepare, review, and retirement through the real shim runner/handler paths, and ran git diff --check. The repository’s Build, Check, Unit Test (Linux and both Windows shards), Sandbox, integration, evaluation, daemon-store, chart, and semantic checks are all green. Local package execution was unavailable because this review runner has no pnpm/Corepack or installed dependencies; that is an environment-only verification gap.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
The regression
A sandboxed agent with session isolation could not start a session on a managed pool. The console showed the allocation, then the refusal:
On a pool member the daemon does not spawn Git at all. Every workspace invocation crosses the shim's
execchannel and is judged there againstALLOWED_GIT_SUBCOMMANDS(packages/daemon/src/shim/exec-handler.ts), because the process that spawns Git is the only one whose check is a control — a declaration on the sending side is not one.checkoutis not on that list, andWorkspaceManager.checkoutSessionBranchrancheckout --no-track -b <branch> <target>.The trigger was #1766, which moved the pool from the worktree tier to the per-session clone tier. The worktree tier created its branch with
worktree add -b, andworktreeis admitted; the clone tier reaches forcheckout -b, which never had to be permitted before.Why every verification missed it. The git runner resolver is wired only for the in-cluster plane (
daemon.ts→k8sPlane.gitRunnerFor). A self-hosted daemon — sandbox on or off — runs Git locally throughLocalGitRunnerand never meets the inventory, so the confined tier is fully exercised there and passes. The pool is the only place the list is consulted, which makes this class of bug silent everywhere it is easy to test.What I chose, and why
Express the operation out of subcommands already admitted; do not widen the list.
checkoutSessionBranchnow runs three invocations instead of one:All three are in the inventory today. Both properties the previous call depended on are preserved:
--no-trackis passed explicitly tobranch(a session branch that trackedorigin/<base>would turn the console's push button into a remote-branch creator), and all three run undersessionCloneGitEnv, so the materializing step keepsGIT_NO_LAZY_FETCH=0and a blobless partial clone may still fetch from its promisor remote.reset --hardis also already how the sibling review-resume branch, three lines below, materializes the same clone — so this makes the two paths consistent rather than inventing a mechanism.Why the rejected option is worse. Admitting
checkoutwould be a permanent widening bought to avoid a three-line rewrite. That file's header is explicit that the list is small on purpose: anything reaching arbitrary Git reaches arbitrary execution through-c, hooks and--upload-pack, so each member is a deliberate admission and the reviewer of the next one inherits whatever this one lets in.checkoutis not a narrow verb — it writes working-tree files, takes pathspecs and--, and has forms (--orphan,-f,<tree-ish> -- .) that have nothing to do with the one shape the daemon issues. Constraining it to that shape would mean a newREFUSED_SUBCOMMAND_ARGUMENTentry enumerating whatcheckoutmay not carry, which is the same allowlist problem one level down and goes stale the way the file already warns operand allowlists do. Meanwhile the operation is not inherently a checkout: creating a branch at a target and pointing HEAD there is exactlybranchplussymbolic-ref, and materializing the tree is exactlyreset --hard. The smaller trusted surface wins, and nothing is lost.The second gap, found by enumerating rather than waiting
Retirement of a confined session has the same shape of hole.
removeSessionClonesprobed for a daemon-owned review snapshot withfor-each-ref --count=1 refs/agentconnect/reviews/<id>, andfor-each-refis not admitted either. Worse, that probe carries its own.catch(() => ''), so on a pool member the refusal was swallowed and every review clone read as "not a snapshot": a dirty or unique-commit review clone was retained forever instead of removed, and its session pod's claim and volume went with it. Silent, unlike thecheckoutcase.It now reads the head ref with
show-ref --verify(admitted). That is faithful: a review fetch always writes and verifies<refRoot>/headbefore anything else is stored, so its presence is whatfor-each-refover the root was answering.--quietis deliberately not used — its silent exit 1 is resolved as success by the local runner, the trap already documented ondrawSessionBranch. The identical probe in the legacy worktree path is changed with it, since it is broken in the same way on a pool member that still holds one.Clone-tier Git versus the inventory
Every subcommand the confined session path issues, traced through
prepareClusterConfinedSession→prepareRootSessionClone→checkoutSessionBranch/fetchReviewRevisionIn, and throughremoveSessionWorktree→removeSessionClones:clonecloneSessionRootAt(blobless,--single-branch)configassertSafeWorkspaceGitConfig,writeRepoHelperConfigcheck-ref-formatremotels-remotefetchupdate-refrev-parserev-listshow-refsymbolic-refbranchresetcleanstatuslog,diff,pullcheckoutcheckoutSessionBranchfor-each-refNothing else on that path is outside the list.
One finding outside this change's scope, reported not fixed. The console's own workspace Git write surface (
cp/workspace-git.ts, reached throughconsoleWorkspaceGitRunner, which returns the remote runner in sandbox mode) issuesadd,commit,pushandls-files, none of which are in the inventory. That is not clone-tier work — it applies equally to a shared session on the agent pod and predates this tier — andpush's exclusion is asserted as intentional in the existing suite, so whether that surface should work on a pool member is a product decision rather than a patch. Flagged separately.Tests
workspace-session-clone.test.tsgains a runner that wires the realShimGitRunnerto the realcreateExecHandler, so the daemon's argv crosses the production exec channel and meets production inventory enforcement instead of a permissive stand-in. Two cases: a confined session's branch is drawn and its tree materialized across that channel (asserting the tree contents, the absent upstream, andGIT_NO_LAZY_FETCH=0on the materializing run), and a whole confined life — prepare, review, dirty, retire — issues nothing outside the inventory, asserted as a set difference so the next addition is caught here rather than in the field.shim-exec-handler.test.tsruns the three-subcommand sequence against a real repository at the sandbox boundary and pins thatcheckoutandfor-each-refstay refused.checkoutSessionBranchserves both tiers, and the existing local-git suite (25 cases inworkspace-session-clone.test.ts, all against real repositories) passes untouched.Mutation check
checkout --no-track -bExecRefusedError: git checkout is not in the permitted inventory, plus 4 incluster-workspace-preparefor-each-refprobesretained/dirtywhereremovedwas expected, which is the live consequence, plus 3 inworkspace.test.tsREFUSED_ARGUMENT-cin all spellings,--exec-path,--upload-pack,--config-env)checkout+for-each-refto the inventoryGates:
tsc -p tsconfig.typecheck.json --noEmitclean;shim-exec-handler,workspace-session-clone,cluster-workspace-prepare,workspace,k8s-session-pods,k8s-runtime-plane,workspace-git-runner-seam,workspace-git-read/write,git-runner-contract,k8s-driver,runtime-launch,workspace-repo-scope— 209 + 119 + 136 passed. One failure,refuses a clone TARGET outside the workspace root, is pre-existing and macOS-local (/varvs/private/var); verified failing on the unmodified file at this base. Prettier and ESLint clean on every touched file.🤖 Generated with Claude Code