Skip to content

fix(tool): enforce isolated-child git safety in the bash tool (mechanism, not prompt text) - #1959

Closed
wqymi wants to merge 1 commit into
mainfrom
feat/isolated-child-git-guard
Closed

fix(tool): enforce isolated-child git safety in the bash tool (mechanism, not prompt text)#1959
wqymi wants to merge 1 commit into
mainfrom
feat/isolated-child-git-guard

Conversation

@wqymi

@wqymi wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Why

packages/opencode/src/session/prompt/orchestrator.txtIntegrating an isolated child's work) already tells an isolated child that it must operate only in its own worktree and touch only its own branch — never git rebase / git merge / git checkout a branch it does not own.

That rule had zero enforcement. And it matters at the mechanism level, not the etiquette level: all worktrees of a repository share ONE .git/ ref store (--git-common-dir). A child running git rebase main or git checkout main inside its own worktree therefore writes refs the MAIN checkout is sitting on and can move that checkout's HEAD, corrupting the working tree the user and other agents are actively using. This has happened for real in this project.

A prior investigation concluded that a model-behaviour test can at most sample whether the orchestrator mentions the rule in a dispatched brief, and that the enforceable fix is mechanism-level: reject cross-branch git operations in an isolated child's bash tool. This is that PR.

Where it hooks, and why there

packages/opencode/src/tool/bash.ts — inside execute, immediately after the command is parsed and before collect/ask:

const root = yield* parse(params.command, ps)
const own = Instance.directory
if (IsolatedGit.isIsolatedWorktree(own)) {
  IsolatedGit.assertIsolatedGitAllowed({ commands: , sources: , directory: own, isolated: true, branch: , isPath:  })
}
const scan = yield* collect(root, cwd, ps, shell)

One choke point, chosen because:

  • It reuses the same tree-sitter AST the permission scan already walks (commands(root) / parts(node)), so every command node in a pipeline, an && chain or a subshell is inspected — no second parser, no regex over the raw string.
  • It runs before ask(), so a rejected command never prompts the user and never spawns a process.
  • It is keyed on Instance.directory, not on the command's cwd: a child that cds into the main checkout and rebases there is precisely the accident this exists to stop, and keying on cwd would silently disable the guard for it.

Isolation signal

Instance.directory under the app-managed worktree root path.join(Global.Path.data, "worktree").

  • session create --isolate creates the child's worktree at <data>/worktree/<projectID>/<name> (src/worktree/index.ts makeWorktreeInfo), and src/tool/session.ts spawns the peer with cwd: effectiveDir = that path; spawn.ts binds it as the child fiber's InstanceRef, which Instance.directory reads.
  • Nothing persists an "isolated" bit on a session record (no Worktree.get(sessionID), no isolated field on Session.Info), so directory containment is the reliable signal available synchronously at bash-execution time. Same trusted-root test already used by src/tool/external-directory.ts.
  • An orchestrator or ordinary session runs in the user's project directory → not under that root → guard never applies. A --isolate that degraded to shared (non-git dir) also evaluates false, correctly.

One wrinkle worth flagging for future callers of this pattern: Instance.provide stores a realpath-resolved directory, so a raw string-prefix test misses on a symlinked prefix (macOS /var/private/var). isIsolatedWorktree compares realpaths as a fallback. The existing external-directory.ts check has the same latent fragility.

The child's own branch is read without spawning git: <worktree>/.gitgitdir: pointer → HEADref: refs/heads/<branch>. Best-effort; undefined on a detached HEAD or unreadable layout, in which case the ownership-sensitive rules fail closed (those forms are destructive and a child never needs them).

Blocked

form why
git rebase … rewrites history against another branch, writes shared refs
git merge … integration is the orchestrator's job
git checkout <branch> / git switch <branch> / git checkout - moves this worktree's HEAD onto a ref it does not own
git checkout --detach / git switch --detach same
git checkout -B <other> / git switch -C <other> force-create resets an existing foreign branch
git branch -d/-D/--delete/-f/--force/-m/-M/--move <other> deletes / force-moves / renames a foreign ref
git push --force/-f/+refspec to a non-own branch rewrites a branch it does not own
git push --delete / :refspec for a non-own branch deletes a foreign remote branch
git worktree add/remove/move/prune/repair/lock/unlock mutates the shared worktree registry
git update-ref (non-own ref), git symbolic-ref <name> <ref> writes the shared ref store / repoints HEAD directly

Leading git global options are skipped before the subcommand is read, so git --no-pager rebase main, git -c core.pager=cat merge main and git -C /elsewhere checkout main are all still seen.

Allowed (regression-tested, because a false block breaks real work)

git add, git commit (incl. --amend), git status, git diff, git log, git show, git fetch, git stash/stash pop, git restore,
git push / git push origin HEAD / git push -u origin <own> / git push --force origin <own> / --force-with-lease <own>,
git checkout -b <new> / git switch -c <new>, git checkout <own-branch>, git checkout -B <own-branch>,
git checkout -- <file>, git checkout ., git checkout <existing-path>, git checkout HEAD -- <file>,
git rebase --abort/--continue, git merge --abort, git worktree list, git branch, git branch --show-current, git symbolic-ref HEAD.

git checkout <arg> is disambiguated by whether <arg> exists on disk, plus two structural facts that mean git cannot be switching branches: a -- separator, or 2+ positionals (tree-ish + paths).

Deliberately NOT blocked

  • git reset (incl. --hard) — it can only move the current worktree's own branch, so it structurally cannot corrupt another checkout's HEAD. Its destructive form is already behind the forced-ask bash_delete prompt in bash.ts. A rule here would be theatre.
  • git pull — only fast-forwards/merges into the child's own branch; never writes another branch's ref.

Honest limitation: this is not a sandbox

A string/argv-level check over the parsed AST cannot stop a determined command. eval, sh -c "$(printf …)", a wrapper script, a repo-configured git alias, or $GIT_DIR env tricks will all evade it. (git -C and leading global options are handled, but that is opportunistic, not a guarantee.)

The goal is explicitly narrower and worth stating plainly: stop the accidental HEAD-moving mistake that actually happens, and when it is stopped, say clearly what to do instead. A hostile process is out of scope, and pretending otherwise would be worse than nothing.

Error output

Blocked in an isolated child session: git rebase main
Reason: `git rebase` rewrites history against another branch and writes the shared ref store.

Every worktree of this repository shares ONE .git/ ref store, so a cross-branch git
operation run from inside your worktree writes refs the MAIN checkout is using and can
move its HEAD — corrupting the working tree the user and other agents are in right now.

  your worktree: /…/worktree/p_abc/child-1
  your branch:   feat/child-work

Do this instead:
  - work only in your worktree, on your own branch
  - `git add` + `git commit` + `git push` YOUR branch, and nothing else
  - ask the orchestrator to rebase/merge/land it — cross-branch integration is its job

Second, separate fix in here: grant-approval never reached background subagents

While investigating a related report (an external_directory grant "lapsing" mid-task and killing a subagent's bash/read/edit access to a worktree outright), I found a real gap on current main:

  • session grant-approval <child|all> writes forwardRef.setGrant, which is write-through to SQLite precisely so it survives restarts and separate-process children.
  • But forwardRef.grantAllowed is consulted in exactly one place: the input.forward branch of Permission.ask (src/permission/index.ts).
  • decideAskRouting (src/agent/config.ts) routes an ordinary background subagent to inherit, never to forward — only orchestrator peers get forward.

Net effect: for a background subagent, grant-approval silently did nothing, even though the tool replies "Future permission asks from child X will be auto-approved." The child's ask fell through to the non-interactive gate and hard-denied. The inherit path's own mechanism (forwardRef.getParentGrants) is an in-memory-only Map, populated as a side effect of the parent's ask() calls and dropped on session delete — so it is absent for a separate-process/isolated child and for a parent that never asked, which is exactly what "the grant lapsed mid-task" looks like from the child's side.

Fix is three lines at the point main's own design makes the decision — honor the DB-backed explicit grant inside the inherit branch:

if (needsAsk && input.inherit && !forced) {
  if (forwardRef.grantAllowed(input.inherit.parentSessionID, request.sessionID)) return
  const parentSnapshot = forwardRef.getParentGrants(input.inherit.parentSessionID)
  

Deliberately not a re-application of the older stranded approach (see below): no grantResolved parameter, no decideAskRouting signature change, no re-routing of subagents to forward. Deny-precedence is untouched (it sits after the deny loop) and forced-ask (bash_delete) still falls through — both regression-tested.

Tests

New:

  • test/tool/isolated-git-guard.test.ts — 85 tests: every blocked form, every allowed form, global-option smuggling, unknown-own-branch fail-closed behaviour, the isolation signal, ownBranch parsing, and the error text.
  • test/tool/bash-isolated-git-guard.test.ts — 4 end-to-end tests through the real BashTool.execute, proving the guard is actually wired (not just a unit-tested module) and that a non-isolated session is unaffected.
  • test/permission/inherit.test.ts — 5 added tests for the grant fix (incl. no-grant fails closed, foreign-child grant does not leak, forced-ask not auto-approved).

Intentional-failure proof (revert only the src change, observe the failure)

probe reverted observed
A hook disabled in bash.ts 2 pass / 2 failExpected to contain: "Blocked in an isolated child session" / Received: ""
B violates() neutered to always allow 54 pass / 35 failexpect(received).toBeTruthy() / Received: undefined across every blocked form
C isolation gate removed inside assertIsolatedGitAllowed 84 pass / 1 fail"a NON-isolated session is completely unaffected" fails with the full guard error thrown
D bash.ts applies the guard to every session non-isolated bash test fails, guard error raised for a plain tmpdir session
E grantAllowed check removed from the inherit branch 8 pass / 2 failExpected: "Success" / Received: "Failure"

Suite results (this branch, --timeout 120000 from packages/opencode)

  • test/tool test/permission test/agent test/cli1314 pass / 10 skip / 0 fail (1324 tests, 108 files)
  • test/tool alone → 837 tests, 0 fail (748 pre-existing + 89 new)
  • test/cli test/permission417 pass / 0 fail
  • bun typecheckexit 0

Pristine main baselines measured previously with the identical command were test/cli + test/permission → 396/0 and test/tool → ~745 pass with 0 failures; both failure sets here are still empty, and the test-count deltas are the newly added tests plus main moving forward.

Investigation: the two PR #1624 rebase content divergences

Closed PR #1624's branch fix/orchestrator-tui-and-infra-bugs had two commits whose landed twins on main differ in content. Verdicts:

1. c05fe6f92 vs landed 426543c22 — REAL LOSS (178 changed lines → 4), but superseded, so NOT re-applied as-is.

The landed twin is comment-only: 2 lines in src/session/prompt.ts. The stranded commit's actual mechanism — a grantResolved field on decideAskRouting, resolved via forwardRef.grantAllowed, routing a granted background subagent to forward — plus its 5 unit tests and 2 permission tests were all dropped. git grep grantResolved origin/main → absent.

But main did not simply lose the capability: it later implemented a different and broader mechanism, the inherit routing (decideAskRouting returns { interactive: false, inherit: { parentSessionID } }, consumed by the parent-grant-inheritance branch in Permission.ask), which consults the parent's actual ruleset+approved snapshot with correct two-phase deny precedence. That is a deliberate adaptation and strictly better for path-based grants. Re-applying the old hunks would be a revert in disguise — the signature and the routing model both changed underneath.

What genuinely remained lost is the narrower thing above: explicit grant-approval never reaching a background subagent, because grantAllowed is only consulted on the forward path. That is the residual cause consistent with the reported symptom, and it is fixed here as a small re-expression against current main rather than a re-apply. The separate persistence fix a0ced0655241a6f048 is fully on main and is untouched.

2. b789c100f8 vs landed e36926f7d — NO LOSS. Pure rebase artifact.

$ for c in b789c100f8 e36926f7d; do git show $c --format= | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | shasum; done
355c3ba54344743f54ad3a61dd45d7443e2656b5  -
355c3ba54344743f54ad3a61dd45d7443e2656b5  -

The changed lines are byte-identical. The whole diff-of-diffs is blob indices, hunk line offsets (@@ -458,11@@ -484,12), and one extra context line (skipPermissions,) that exists on main but not on the stranded branch's base. Nothing to do.

Left alone deliberately

  • orchestrator.txt prose — unchanged; the prompt guidance is still correct and now backed by a mechanism.
  • session create --isolate's existing git-safety warning — unchanged.
  • The inherit design itself, and the in-memory-only nature of parentGrants — I only added the DB-backed explicit-grant check. Making the parentGrants snapshot durable/cross-process is a real but larger design question and belongs in its own PR.
  • git reset / git pull — see Deliberately NOT blocked above.

All worktrees of a repo share ONE .git/ ref store, so an isolated child
session running `git rebase main` or `git checkout main` inside its own
worktree writes refs the MAIN checkout is sitting on and can move its
HEAD — corrupting the tree the user and other agents are working in. This
has happened for real. Until now the rule existed only as prompt text in
orchestrator.txt with zero enforcement.

Add a cross-branch git guard on the bash tool's existing parsed-command
choke point. It fires only for isolated children (Instance.directory under
`<data>/worktree/…`), so the orchestrator and ordinary sessions still
integrate branches freely. Blocks rebase/merge, branch switches, branch
delete/force-move, foreign force-push/delete-push, worktree mutations and
direct ref writes; leaves the child's own add/commit/push/status/diff/
stash/file-restore/new-branch work untouched. The error names what was
blocked, why, and what to do instead.

Also honor an explicit `session grant-approval` on the parent-grant
inheritance path: decideAskRouting routes an ordinary background subagent
to `inherit`, never to `forward`, so the DB-backed grant was consulted
nowhere on a subagent's path — the documented command silently did nothing
and the child's external_directory ask failed closed mid-task.
@wqymi

wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #1741 as 6acd5c2 (cherry-picked cleanly, no conflicts). Verified on the remote by content, not by oid: src/tool/isolated-git-guard.ts, test/tool/isolated-git-guard.test.ts and test/tool/bash-isolated-git-guard.test.ts are all present on docs/orchestrator-route-first-redesign, and bash.ts still carries the hook. Both sides' tests pass together: 160 pass / 1 skip / 0 fail across the four owning files, bun typecheck exit 0.

Reason: this mechanism exists because #1741's prompt-level rule was measured not to hold — mimo-v2.5 merged into a protected branch in 3 of 3 live runs even though orchestrator.txt forbids it and the driving message ended with "your call". A prompt rule and the tool-layer mechanism that enforces it are one problem, so they ship as one PR. The permission/index.ts change is the grant-routing fix this guard's own rejection path needs.

Branch retained.

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