Skip to content

fix(gjc): ask before a shared-checkout run rewrites git state - #167

Merged
devswha merged 1 commit into
mainfrom
fix/ask-before-shared-checkout-git-changes
Sep 17, 2026
Merged

devswha merged 1 commit into
mainfrom
fix/ask-before-shared-checkout-git-changes

Conversation

@devswha

@devswha devswha commented Sep 17, 2026

Copy link
Copy Markdown
Owner

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:

02:04  HEAD = fix/promotion-gates                          working tree dirty (7 files)
02:12  HEAD = f14c006 [fix/git-checkout-filter-isolation]     clean

An unattended run committed, pushed and switched branches under a live reader. Nothing asked.

Why bypass cannot be honoured here

Every project on the machine where this was found is set to bypass, warning acknowledged:

$ sqlite3 ~/.gajae-app/auth.db "select project_path, mode from project_permissions"
/Users/devswha/myjob                      bypass
/Users/devswha/workspace/shot-demo        bypass
/Users/devswha/Documents/CV               bypass
/Users/devswha/workspace/gajae-code-app   bypass
/Users/devswha/workspace                  bypass

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

createGjcPermissionProvider already runs inside the worker for every gated tool call, before anything executes, and already receives rawInput. 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:

managed worktree   cwd = the checkout,  projectPath = repository root   →  different  →  isolated
project location   cwd = projectPath                                     →  same       →  shared

A path that cannot be resolved reads as shared, so unknown asks rather than assumes.

What still passes untouched

bypass keeps approving everything that does not rewrite shared git state — npm test, git status, git log, git diff. And an ask-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:

assert.equal(mutatesSharedGitState('G=git; $G commit -m x'), false);   // computed command name
assert.equal(mutatesSharedGitState('bash ./release.sh'), false);        // contents never seen
assert.equal(mutatesSharedGitState('gh pr merge 1 --squash'), false);   // another program, same plumbing

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 on gitk, mygit or github-cli, nor on reads.

Engine manifest

server/gjc-shared-checkout-guard.ts runs inside the worker, so it is registered in gjc-engine-manifest.json — the manifest's own comment requires the choice to be deliberate:

"A server/gjc-* file missing from both lists fails npm test: a new file in the engine's namespace is a decision about what ships closed, and it should not be possible to make it by accident."

Verification

npm test                                       1487 / 540 / 407 tests   0 fail
node --test gjc-shared-checkout-guard.test.ts    11 pass  0 fail
node --test gjc-bun-permission-gate.test.ts      16 pass  0 fail   (11 existing + 5 new)
bun test  gjc-sdk-contract.bun.test.ts          126 pass  0 fail   (+2 new)
node --test gjc-engine-manifest.test.ts           6 pass  0 fail
npm run lint                                    clean
npm run typecheck                               clean

The two contract tests drive the live session.start path: a shared checkout produces an ask.presented frame for git commit under bypass while npm test is still auto-approved, and an isolated checkout produces no card at all.

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
@devswha
devswha merged commit 5857364 into main Sep 17, 2026
6 checks passed
@devswha
devswha deleted the fix/ask-before-shared-checkout-git-changes branch September 17, 2026 06:57
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