Skip to content

fix(daemon): keep the confined session tier inside the sandbox git inventory - #1783

Merged
zfy0701 merged 1 commit into
mainfrom
fix/shim-clone-tier-git-inventory
Sep 3, 2026
Merged

zfy0701 merged 1 commit into
mainfrom
fix/shim-clone-tier-git-inventory

Conversation

@zfy0701

@zfy0701 zfy0701 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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:

⏳ Allocating a sandbox pod…
⚠️ git checkout is not in the permitted inventory

On a pool member the daemon does not spawn Git at all. Every workspace invocation crosses the shim's exec channel and is judged there against ALLOWED_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. checkout is not on that list, and WorkspaceManager.checkoutSessionBranch ran checkout --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, and worktree is admitted; the clone tier reaches for checkout -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 through LocalGitRunner and 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.

checkoutSessionBranch now runs three invocations instead of one:

branch --no-track <branch> <target>     # create the branch, no upstream
symbolic-ref HEAD refs/heads/<branch>   # point HEAD at it
reset --hard                            # materialize index and working tree at that HEAD

All three are in the inventory today. Both properties the previous call depended on are preserved: --no-track is passed explicitly to branch (a session branch that tracked origin/<base> would turn the console's push button into a remote-branch creator), and all three run under sessionCloneGitEnv, so the materializing step keeps GIT_NO_LAZY_FETCH=0 and a blobless partial clone may still fetch from its promisor remote. reset --hard is 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 checkout would 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. checkout is 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 new REFUSED_SUBCOMMAND_ARGUMENT entry enumerating what checkout may 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 exactly branch plus symbolic-ref, and materializing the tree is exactly reset --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. removeSessionClones probed for a daemon-owned review snapshot with for-each-ref --count=1 refs/agentconnect/reviews/<id>, and for-each-ref is 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 the checkout case.

It now reads the head ref with show-ref --verify (admitted). That is faithful: a review fetch always writes and verifies <refRoot>/head before anything else is stored, so its presence is what for-each-ref over the root was answering. --quiet is deliberately not used — its silent exit 1 is resolved as success by the local runner, the trap already documented on drawSessionBranch. 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 through removeSessionWorktree → removeSessionClones:

Subcommand Where In the inventory
clone cloneSessionRootAt (blobless, --single-branch) yes
config assertSafeWorkspaceGitConfig, writeRepoHelperConfig yes
check-ref-format credential/branch validation yes
remote origin convergence on resume yes
ls-remote secondary root default branch yes
fetch review base/head/merge into the clone yes
update-ref drops a stale merge ref yes
rev-parse HEAD and exact-revision verification yes
rev-list merge parents, retirement's unique-commit rule yes
show-ref session-branch draw, and now the snapshot probe yes
symbolic-ref now points HEAD at the session branch yes
branch now creates the session branch yes
reset now materializes; review re-delivery already did yes
clean review re-delivery yes
status retirement's dirty rule yes
log, diff, pull console reads, primary refresh yes
checkout was checkoutSessionBranch no — replaced
for-each-ref was the snapshot probe (2 sites) no — replaced

Nothing 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 through consoleWorkspaceGitRunner, which returns the remote runner in sandbox mode) issues add, commit, push and ls-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 — and push'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.ts gains a runner that wires the real ShimGitRunner to the real createExecHandler, 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, and GIT_NO_LAZY_FETCH=0 on 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.ts runs the three-subcommand sequence against a real repository at the sandbox boundary and pins that checkout and for-each-ref stay refused.
  • Self-hosted behaviour is unchanged: the same checkoutSessionBranch serves both tiers, and the existing local-git suite (25 cases in workspace-session-clone.test.ts, all against real repositories) passes untouched.

Mutation check

Mutation Red
Restore checkout --no-track -b 6 — both new shim-backed cases fail with the live message, ExecRefusedError: git checkout is not in the permitted inventory, plus 4 in cluster-workspace-prepare
Restore both for-each-ref probes 4 — the enumeration case fails with retained/dirty where removed was expected, which is the live consequence, plus 3 in workspace.test.ts
Empty REFUSED_ARGUMENT 3 — every argument-guard case (-c in all spellings, --exec-path, --upload-pack, --config-env)
Add checkout + for-each-ref to the inventory 2 — the refusal loop and the new sequence case

Gates: tsc -p tsconfig.typecheck.json --noEmit clean; 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 (/var vs /private/var); verified failing on the unmodified file at this base. Prettier and ESLint clean on every touched file.

🤖 Generated with Claude Code

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

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@zfy0701
zfy0701 merged commit 79f1ea3 into main Sep 3, 2026
14 checks passed
@zfy0701
zfy0701 deleted the fix/shim-clone-tier-git-inventory branch September 3, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant