Skip to content

Add a scoped push path so agents stop toggling global git config - #371

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-4762-agents-toggle-global-git-config-to-push-needs-a-scoped
Aug 7, 2026
Merged

Add a scoped push path so agents stop toggling global git config#371
TheGreatAxios merged 2 commits into
mainfrom
cl-4762-agents-toggle-global-git-config-to-push-needs-a-scoped

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add bin/git-push-scoped: pushes to GitHub over HTTPS using gh's
    credential helper and an SSH-to-HTTPS URL rewrite, both scoped to
    a single git push invocation via -c. It never writes to any
    git config file, local or global.
  • Add a git-global-config rule to AUTO_SHELL_RULES in
    src/permission/auto-shell-policy.ts so auto mode routes
    git config --global/--system/--edit/--file and unsetting
    GIT_CONFIG_GLOBAL to an operator ask instead of running them
    unattended. This is the actual gate; AGENTS.md alone only asks
    nicely and cannot stop an agent from ignoring it.
  • Broaden the AGENTS.md rule to the property ("never mutate git
    configuration outside the current repository") instead of naming
    two commands, and point at bin/git-push-scoped as the sanctioned
    path 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:.insteadOf rewrite, pushing over HTTPS, then
restoring 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 stash collision
already seen in this project.

A script alone doesn't prevent the old behavior — nothing forced
agents to use it. The auto-shell-policy.ts rule is what actually
closes the gap: it is the same table-driven mechanism that already
forces git worktree add/remove/prune to ask because it changes a
boundary the workspace doesn't own, and global git config mutation
is the same category.

Bypass route checked, not changed: writing ~/.gitconfig
directly via write_file/edit_file skips shell policy entirely,
but it's already caught by the existing outside-workspace path
restriction (src/permission/gate.ts, callTargetsRestricted) since
$HOME sits outside the workspace boundary — that path already
drops to ask for any path-keyed tool. No new gate was needed there.

Verification

  • New integration test tests/integration/git-push-scoped.test.ts
    isolates HOME/GIT_CONFIG_GLOBAL to a throwaway sentinel file so
    it 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.
  • New tests in src/permission/classify-security.test.ts cover the
    git-global-config auto-shell rule: --global, --system,
    --edit, --file (both outside- and inside-workspace targets),
    unset GIT_CONFIG_GLOBAL, that GIT_CONFIG_GLOBAL= reassignment
    is caught by the existing env-assignment rule, and that plain
    repo-local git config reads/writes are unaffected. Confirmed red
    first (rule absent → failures), then green.
  • bun run typecheck, bun run build, bun run test all pass
    (4048 pass, 0 fail).
  • bun test ./src ./tests ./evals --randomize --seed 42 passes
    (4048 pass, 0 fail).
  • Confirmed on the real machine that global git config
    (user.name, user.email, url.*.insteadOf) was unchanged
    before and after this work, and this branch was pushed — including
    the force-with-lease update for this revision — using
    bin/git-push-scoped itself.

Closes CL-4762

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CL-4762

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.
@TheGreatAxios
TheGreatAxios force-pushed the cl-4762-agents-toggle-global-git-config-to-push-needs-a-scoped branch from e483b6b to d7dad61 Compare August 7, 2026 15:16
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Review summary — two rounds, held open for human review

Ready for your call. Not merged.

The gap the first round left

The scoped push script was well built, but nothing called it and nothing stopped the old behaviour — an agent in auto mode could still run git config --global ... with no gate. The only enforcement was a prose rule in AGENTS.md asking it not to.

That is now fixed at the layer that owns the constraint. src/permission/auto-shell-policy.ts gains a git-global-config ask rule, using the same table-driven mechanism that already forces git worktree add/remove/prune to prompt. It covers --global, --system, --edit, --file, and unset GIT_CONFIG_GLOBAL; GIT_CONFIG_GLOBAL= reassignment was already caught by the existing env-assignment rule.

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

write_file/edit_file on ~/.gitconfig skips shell policy entirely, since auto-shell-policy.ts returns early for anything that is not run_shell. The author reported this as already gated by the workspace boundary rather than adding redundant code. Confirmed directly against the real createPathRestriction:

~/.gitconfig   write-restricted: true   read-restricted: true

Stating it as a finding rather than writing a second guard was the right call.

Verified in the first round, still holding

  • Sentinel global config byte-identical after a normal push, after two concurrent pushes, and after a SIGKILL mid-flight
  • No credential exposure: ps shows only -c credential.helper=!gh auth git-credential — the helper command, not a token. A planted token string appeared nowhere on disk
  • Failure modes clean: gh missing exits 1 with a clear message; auth failure exits 128 with no silent fallback
  • Script hygiene solid: set -euo pipefail, quoted expansions, arguments exec'd straight into git's argv so a hostile branch or remote name cannot inject

AGENTS.md now states the property — never mutate git configuration outside the current repository — rather than enumerating two command forms, which invited treating everything else as fine. The commit body was trimmed to motivation only.

Typecheck and build clean. 4048 pass / 0 fail, identical under --randomize --seed 42. Commit hygiene clean: subjects and bodies within 72, no ticket references, nothing under vendor/.

@TheGreatAxios
TheGreatAxios merged commit 6dea027 into main Aug 7, 2026
3 checks passed
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