fix(gjc): ask before a shared-checkout run rewrites git state - #167
Merged
Merged
Conversation
A session that chose the project location runs in the same working tree the user and every other session of that project are looking at. Committing, switching branches or resetting there is not a private act: it moves `HEAD` and the index underneath whoever else is reading the directory. On 2026-09-17 an unattended run committed, pushed and switched branches in a shared checkout while a second session was mid-read, and nothing asked. Every project on that machine was set to `bypass` with the warning acknowledged, so honouring the mode here would leave that case exactly as unprotected as it was. Approving a git state change in advance approves it on behalf of every other session reading that directory, which is not the granting user's to waive turn by turn - so it becomes a card even under `bypass`. The permission provider already runs inside the worker for every gated call, before anything executes, and it already sees `rawInput`. This adds one check in front of the policy lookup. A session in its own managed worktree owns its git state and is never gated, which since #166 is the normal case for a repository - so the card appears exactly when the user opted into the shared checkout, which is when it should. The run's own cwd is the isolation answer: a managed worktree is dispatched with the checkout as `cwd` while `projectPath` stays the repository root, and a project-location run gets the same path for both. A path that cannot be resolved reads as shared, so unknown asks rather than assumes. This is a prompt, not a sandbox. Command text can be written to defeat text matching, and the tests record three such shapes on purpose so the limit is documented rather than discovered. Containment belongs to the runtime. Refs #156
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #156. Follows #166, which restored the run-location picker.
The gap #166 left
#166 made a repository session start in its own managed worktree. It did not stop a session that runs in the shared project checkout from rewriting git state there without asking.
That is the half that caused the incident. On 2026-09-17, while a second session was reading
~/workspace/gajae-code-app:An unattended run committed, pushed and switched branches under a live reader. Nothing asked.
Why
bypasscannot be honoured hereEvery project on the machine where this was found is set to
bypass, warning acknowledged:Honouring the mode would leave the observed failure exactly as unprotected as it was.
Approving a git state change in advance approves it on behalf of every other session reading that directory. That is not the granting user's to waive turn by turn, so it becomes a card even under
bypass. This is also Claude Code's rule: "A session editing a checkout it didn't isolate itself still asks before committing or switching branches."Where the check goes
createGjcPermissionProvideralready runs inside the worker for every gated tool call, before anything executes, and already receivesrawInput. This adds one check in front of the policy lookup — no new plumbing, no new protocol frame.A session in its own managed worktree owns its git state and is never gated. Since #166 that is the normal case for a repository, so the card appears exactly when the user opted into the shared checkout — which is when it should.
How isolation is decided
The run's own cwd, with no extra state to keep in sync:
A path that cannot be resolved reads as shared, so unknown asks rather than assumes.
What still passes untouched
bypasskeeps approving everything that does not rewrite shared git state —npm test,git status,git log,git diff. And anask-mode session sees the same card it always did, with no added explanation, because nothing was overridden.This is a prompt, not a sandbox
Command text can be written to defeat text matching. Rather than imply otherwise, the test suite records three such shapes on purpose:
If any of those ever needs catching, that is a decision to make on purpose, not a regression to discover. Containment belongs to the runtime.
What it does catch: 21 state-changing subcommands, mutations hidden behind
&&/||/;/|/newline, value-taking flags (git -C /elsewhere commit,--git-dir,-c), env prefixes and wrappers (GIT_DIR=x git commit,sudo git push,/usr/bin/git,env … git,command git). It does not fire ongitk,mygitorgithub-cli, nor on reads.Engine manifest
server/gjc-shared-checkout-guard.tsruns inside the worker, so it is registered ingjc-engine-manifest.json— the manifest's own comment requires the choice to be deliberate:Verification
The two contract tests drive the live
session.startpath: a shared checkout produces anask.presentedframe forgit commitunderbypasswhilenpm testis still auto-approved, and an isolated checkout produces no card at all.