Skip to content

feat(hooks): mechanical git-freeze guard for shared-workspace subagents (#2705) - #2718

Merged
automagik-genie merged 2 commits into
devfrom
feat/git-freeze-hook
Jul 27, 2026
Merged

automagik-genie merged 2 commits into
devfrom
feat/git-freeze-hook

Conversation

@automagik-genie

Copy link
Copy Markdown
Contributor

Closes the investigation half of #2705 and lands the guard it asked for.

Verdict: feasible on Claude Code, not portable

#2705 asked whether the shared-workspace git-state freeze can be enforced mechanically at dispatch, and named three false-positive traps: compound commands, git -C <path> forms targeting a worktree the agent owns, and the orchestrator's own operations.

The orchestrator-vs-subagent question — the one the issue flagged as possibly fatal — is answered by the runtime, not by inference. Claude Code's PreToolUse payload carries agent_id and agent_type. Measured empirically on Claude Code 2.1.220 (headless run, hook dumping raw stdin, one main-thread Bash call and one Task-subagent Bash call):

caller agent_id agent_type session_id cwd
orchestrator (main thread) null null cbc35a0d-… <project>
spawned subagent "a59efa9aa4e5c4138" "general-purpose" cbc35a0d-… (same) <project> (same)

Same session, same cwd, different agent_id. Nothing else in the payload distinguishes them, and no environment variable does either — CLAUDE_CODE_SESSION_ID / CLAUDE_PID are identical for both.

Per-runtime assessment (full write-up posted on #2705):

  • Claude Code — expressible. Shipped here.
  • Codex — not expressible. The Codex PreToolUse payload has no subagent identity field; the guard cannot distinguish a Codex subagent shell from the Codex orchestrator, so it fails open there and the freeze stays prose-only on Codex.
  • Warp / genie launch — no hook surface at all; panes are plain shells. Moot: panes are worktree-isolated by construction and already exempt.

What landed

src/hooks/handlers/git-freeze-guard.ts — PreToolUse:Bash, priority 2, registered in src/hooks/index.ts alongside the existing deny-guards.

For a payload that carries agent_id, it:

  1. masks quoted regions (shared with branch-guard, see below) so a frozen command named inside a -m / --body argument is never a match;
  2. splits the command on &&, ||, ;, |, &, newline and walks the statements left to right, tracking literal cd / pushd and dropping to "unknown" on popd;
  3. parses git global flags, honouring -C <path> (chained, relative-resolved);
  4. classifies the subcommand against the freeze list — checkout, switch, reset, stash, rebase — exempting the read-only git stash list / git stash show, and never matching git worktree add/remove/prune/list (permitted orchestrator-side plumbing per AGENTS.md);
  5. denies only when git rev-parse --show-toplevel of the resolved target directory equals that of the session cwd.

Step 5 is what makes git -C <owned-worktree> safe: a linked worktree has its own HEAD and its own top level, so it never compares equal to the shared checkout. That also makes the in-session escape hatch work without any special-casing:

git worktree add <path> -b <branch> <base>   # permitted plumbing
git -C <path> switch <branch>                # allowed: different working tree

The deny message cites the AGENTS.md freeze verbatim and lists all three remedies: take a worktree and address it with git -C, use genie launch <wish-slug> for a whole group, or sequence the mutation back through the orchestrator.

Fail-open, deliberately

Every ambiguity resolves to allow: no agent_id (main thread, Codex, a client that drops the field), a non-literal cd / -C target ($VAR, ~, glob, command substitution, quoted), --git-dir / --work-tree / --namespace overrides, or a top level git cannot resolve.

This is the opposite of branch-guard's fail-closed posture and the inversion is the point. branch-guard can fail closed because server-side branch protection backstops it; the freeze has no backstop, so a wrong deny simply breaks a working agent with no escape hatch. The founding incident was an accidental HEAD move in a shared checkout. A guard that catches the literal common forms and never fires on a legitimate one is worth more than a guard nobody keeps enabled.

Stated plainly in the module header: this is a guardrail, not a sandbox. An agent that wants to route around it (bash -c, a script file, GIT_DIR=, a Python subprocess) trivially can. It closes the accident class, not an adversarial one.

Cost is near-zero on the hot path: no subprocess runs unless a frozen subcommand is actually found in the command text — asserted by two tests.

Drive-by

branch-guard's quote masker moved verbatim to src/hooks/shell-quoting.ts; both Bash classifiers now import one implementation instead of two copies. No behavior change — branch-guard's existing suite is unmodified and green.

Validation

bun run check:fast                       # OK (typecheck, lint, dead-code, skills, wishes,
                                         #     complexity, council, hook-bundles, hook-content,
                                         #     plugin-executables) — 2 pre-existing doctor.ts warnings
bun test src/hooks/__tests__/git-freeze-guard.test.ts   #  60 pass, 0 fail
bun test src/hooks tests/hooks scripts/hook-*.test.ts   # 288 pass, 0 fail (17 files)
bun test                                                # 2884 pass, 1 fail

The single full-suite failure is the known pre-existing macOS-only ui-bridge lifetime > holds zero listening TCP sockets while running (needs Linux ss), unrelated to this change.

Coverage: 60 tests spanning all 13 frozen forms, the orchestrator exemption, the three agent_id-absent paths, 14 allowed non-frozen commands, 6 git -C cases, 9 compound-command cases (including every fail-open trap), 4 quoted-argument cases, and the worktree-scoped-session semantics.

Left for the orchestrator to disposition

AGENTS.md is unchanged. Its "Flip conditions" section still lists #2705 as an open investigation whose infeasibility would be evidence toward flip condition (i). The finding is partial feasibility — mechanical on Claude Code, prose-only on Codex — which is a council call, not an engineer call. The per-runtime analysis is posted on #2705 so it can be weighed there.

…ts (#2705)

The shared-workspace git-state freeze in AGENTS.md — shared-workspace
subagents never run checkout/switch/reset/stash/rebase, only the
orchestrator moves HEAD — was enforced by brief prose alone. #2705 asked
whether a dispatch-level guard could enforce it without false-positives.

It can, on Claude Code. Measured on 2.1.220: the PreToolUse payload
carries agent_id/agent_type. Main-thread Bash calls arrive with
agent_id: null, agent_type: null; a spawned subagent's arrive with
agent_id: "<id>", agent_type: "general-purpose" under the same
session_id and the same cwd. That is the orchestrator-vs-subagent
discriminator the freeze needs, and nothing else in the payload provides
it.

The new PreToolUse:Bash handler (priority 2, alongside the other
deny-guards) walks a compound command left to right, tracks literal
cd/pushd, honours git -C, and denies a frozen subcommand only when
`git rev-parse --show-toplevel` of the target directory equals that of
the session cwd. A linked worktree resolves to a different top level, so
`git worktree add <path>` followed by `git -C <path> switch` — the
documented escape hatch — is untouched.

Every ambiguity fails OPEN, deliberately: no agent_id (main thread,
Codex, a client that drops the field), a non-literal cd/-C target,
--git-dir/--work-tree overrides, or an unresolvable working tree all
allow. Unlike branch-guard, the freeze has no server-side backstop, so a
wrong deny has no escape hatch; the founding incident was an accidental
HEAD move, and a guard that catches the literal forms and never fires on
a legitimate one beats a guard nobody keeps enabled. It is a guardrail,
not a sandbox.

Also lifts branch-guard's quote masker into src/hooks/shell-quoting.ts
so both Bash classifiers share one implementation.

Coverage gap kept explicit: Codex PreToolUse carries no subagent
identity, and `genie launch` Warp panes have no hook surface at all
(they are worktree-isolated by construction, so exempt). Full assessment
per runtime is recorded on #2705.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d1426d17-1a0c-4c74-98f4-f85d22c39807

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/git-freeze-hook

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c4142e40c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/hooks/handlers/git-freeze-guard.ts Outdated
continue;
}
const invocation = parseGitInvocation(tokens, dir);
if (invocation && isFrozenInvocation(invocation)) return invocation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Inspect every frozen invocation before allowing the command

When a compound Bash input first runs a frozen command in another worktree and then one in the shared checkout, such as git -C /wt checkout dev && git reset --hard, this return stops at the first invocation. The handler resolves /wt as a different root and allows the entire tool call, so the second command can reset the shared checkout. git -h confirms -C <path> as a supported global form; continue evaluating all frozen invocations before allowing the payload.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

Comment thread src/hooks/handlers/git-freeze-guard.ts Outdated
Comment on lines +191 to +192
if (tokens[0] === 'cd' || tokens[0] === 'pushd') {
dir = joinPath(dir, tokens[1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve conditional execution when tracking cd

When a cd is conditional on an earlier command, this traversal applies it even if the shell skips it; for example, false && cd /wt; git reset --hard leaves the shell in the shared checkout, but the guard records /wt, resolves the reset against the other worktree, and allows it. Preserve the separators and account for whether a directory transition executes instead of treating every split segment as unconditional.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

…st git call

Two verifier defects in the freeze guard, one in each direction.

A subshell was a false positive. `(cd /other && git switch main)` denied,
because the token `(cd` is not `cd`, so the directory change was dropped and
the git call was judged against the session cwd — a deny against a command
that never touches the shared checkout, in a module whose whole posture is
fail-open. Statements now carry their subshell parentheses: a leading `(`
opens a directory scope and a trailing `)` closes it, so the `cd` is tracked
inside the group and does not leak past it. Both spellings parse, `(cd /x`
and `( cd /x`.

That accounting only holds if the parentheses counted are the shell's
grouping and nothing else, so command and process substitutions are masked
first — the stray `)` of a `$(pwd)` would otherwise close a real group early
and leak its `cd` outward. They mask to `$`, which the non-literal-path test
already rejects, keeping `git -C $(pwd)` failing open as before. The cost is
that a frozen call nested inside a substitution is no longer seen; that gap
is documented in the module header and pinned by a test.

A compound command was a false negative. `git -C /other switch main && git
switch dev` was allowed, because the finder returned the first frozen
invocation and the guard, finding it outside the shared checkout, stopped —
a legitimate lead call shielded a frozen one behind it. Every frozen
invocation is now collected and resolved, and the deny is raised for the
first one that lands in the shared root. The "no git subprocess unless a
frozen subcommand is present" property is preserved, and repeated
directories resolve once.

15 regression tests covering both directions; 60 existing tests unchanged.
@automagik-genie
automagik-genie merged commit b3cd5b1 into dev Jul 27, 2026
17 checks passed
@automagik-genie
automagik-genie deleted the feat/git-freeze-hook branch September 25, 2026 04:49
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