Add a scoped push path so agents stop toggling global git config - #371
Conversation
Worktree agents that can't reach the ssh-agent socket have been working around SSH pushes by removing the global insteadOf rewrite, pushing, then restoring it. That window is shared-mutable state: a concurrent agent's push or fetch fails, and a crash or SIGKILL mid toggle leaves the machine's global config wrong for every repo. bin/git-push-scoped authenticates over HTTPS through gh's credential helper and rewrites the SSH remote to HTTPS, both scoped to a single git push invocation via -c. Nothing is written to any config file, so there is nothing to restore and nothing to race over.
The scoped push script gave agents an alternative, but nothing stopped an agent in auto mode from still running git config --global, --system, --edit, --file against an outside-repo path, or unsetting GIT_CONFIG_GLOBAL — AGENTS.md only asked nicely. Add a git-global-config rule to AUTO_SHELL_RULES, the table auto-shell policy already uses to force worktree and recursive-rm commands to ask instead of auto-running. Machine-wide config mutation is the same category: it changes state outside the workspace boundary. Writing ~/.gitconfig directly with write_file/edit_file bypasses shell policy, but is already caught by the existing outside-workspace path restriction, since $HOME sits outside the workspace boundary. No separate gate is needed for that route. Broaden the AGENTS.md rule to name the property (never mutate git configuration outside the current repository) instead of enumerating two commands, since an incomplete list reads as permission for anything not named.
e483b6b to
d7dad61
Compare
Review summary — two rounds, held open for human reviewReady for your call. Not merged. The gap the first round leftThe scoped push script was well built, but nothing called it and nothing stopped the old behaviour — an agent in auto mode could still run That is now fixed at the layer that owns the constraint. Verified by experiment, not by reading: with the rule present the security tests pass 78/78; with the rule surgically removed, 5 fail. The tests genuinely exercise it. The bypass route, independently confirmed already covered
Stating it as a finding rather than writing a second guard was the right call. Verified in the first round, still holding
Typecheck and build clean. 4048 pass / 0 fail, identical under |
Summary
bin/git-push-scoped: pushes to GitHub over HTTPS using gh'scredential helper and an SSH-to-HTTPS URL rewrite, both scoped to
a single
git pushinvocation via-c. It never writes to anygit config file, local or global.
git-global-configrule toAUTO_SHELL_RULESinsrc/permission/auto-shell-policy.tsso auto mode routesgit config --global/--system/--edit/--fileand unsettingGIT_CONFIG_GLOBALto an operator ask instead of running themunattended. This is the actual gate; AGENTS.md alone only asks
nicely and cannot stop an agent from ignoring it.
configuration outside the current repository") instead of naming
two commands, and point at
bin/git-push-scopedas the sanctionedpath plus the new policy rule as the enforcement layer.
Why
Agents in worktrees that can't reach the ssh-agent socket have been
working around SSH push failures by removing the global
url.git@github.com:.insteadOfrewrite, pushing over HTTPS, thenrestoring it. That's shared-mutable state: a concurrent agent's push
or fetch fails during the toggle window, and a crash or SIGKILL
mid-restore leaves the machine's global config wrong for every repo,
indefinitely — the same hazard class as the
git stashcollisionalready seen in this project.
A script alone doesn't prevent the old behavior — nothing forced
agents to use it. The
auto-shell-policy.tsrule is what actuallycloses the gap: it is the same table-driven mechanism that already
forces
git worktree add/remove/pruneto ask because it changes aboundary the workspace doesn't own, and global git config mutation
is the same category.
Bypass route checked, not changed: writing
~/.gitconfigdirectly via
write_file/edit_fileskips shell policy entirely,but it's already caught by the existing outside-workspace path
restriction (
src/permission/gate.ts,callTargetsRestricted) since$HOMEsits outside the workspace boundary — that path alreadydrops to ask for any path-keyed tool. No new gate was needed there.
Verification
tests/integration/git-push-scoped.test.tsisolates
HOME/GIT_CONFIG_GLOBALto a throwaway sentinel file soit can never touch the real machine's config, and asserts, by
running real git pushes against local bare repos: a normal push
leaves the config byte-identical, two concurrent pushes from
different repos both succeed with config untouched, and a push
killed mid-flight with SIGKILL (via a slow pre-receive hook) leaves
config untouched. Confirmed red first (script removed → ENOENT on
all three), then green.
src/permission/classify-security.test.tscover thegit-global-configauto-shell rule:--global,--system,--edit,--file(both outside- and inside-workspace targets),unset GIT_CONFIG_GLOBAL, thatGIT_CONFIG_GLOBAL=reassignmentis caught by the existing env-assignment rule, and that plain
repo-local
git configreads/writes are unaffected. Confirmed redfirst (rule absent → failures), then green.
bun run typecheck,bun run build,bun run testall pass(4048 pass, 0 fail).
bun test ./src ./tests ./evals --randomize --seed 42passes(4048 pass, 0 fail).
(
user.name,user.email,url.*.insteadOf) was unchangedbefore and after this work, and this branch was pushed — including
the force-with-lease update for this revision — using
bin/git-push-scopeditself.Closes CL-4762