From 47d89b51eff09bd60fd685a68fe36a9af65a049b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 07:57:59 -0700 Subject: [PATCH 1/2] Add a scoped push path so agents stop toggling global git config 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. --- AGENTS.md | 12 ++ bin/git-push-scoped | 52 +++++++++ tests/integration/git-push-scoped.test.ts | 134 ++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100755 bin/git-push-scoped create mode 100644 tests/integration/git-push-scoped.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9c445f7db..6e3ab48d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,18 @@ counts meaningless to compare across branches — always use `bun run test`. Follow the `style` skill's message format: plain-English summary, no `feat:`/`fix:` prefixes, no filename in the summary. Separate refactors from feature additions. Commit with the user's local git identity. +## Pushing + +**Never mutate global git config**, for any reason — not `git config --global`, not toggling `url.*.insteadOf`, not even temporarily with a plan to restore it. Global config is shared-mutable state across every agent and every repo on the machine; a crash or a second agent running concurrently turns a "temporary" toggle into a lasting outage or collision. This is the same hazard class as running `git stash` (also global, also banned). + +If SSH push fails because the shell can't reach the ssh-agent socket, use `bin/git-push-scoped` instead of touching config: + +```bash +bin/git-push-scoped origin +``` + +It authenticates over HTTPS via `gh`'s credential helper and rewrites the SSH remote to HTTPS, both scoped to that one `git push` invocation with `-c`. Nothing is written to any config file, so there is nothing to restore and nothing to collide over. + ## Building on Interchange Interchange is the standard library for this repo, consumed as published `@intx/*` npm packages pinned at 0.2.2 (`@intx/inference` resolves to the vendored copy in `vendor/intx-inference` — upstream 0.2.2 plus the audited patch set on CL-4352). We never modify or push to the upstream interchange repository. Before writing any new infrastructure — plugins, middleware, utilities, state management, logging, authz, inference, tools — check these packages. diff --git a/bin/git-push-scoped b/bin/git-push-scoped new file mode 100755 index 000000000..50a0114a5 --- /dev/null +++ b/bin/git-push-scoped @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Push to a GitHub-hosted remote without touching global git config. +# +# Worktree agents often cannot reach the local ssh-agent socket, so an +# SSH-configured "origin" (git@github.com:...) fails to push. The old +# workaround toggled the *global* `url.git@github.com:.insteadOf` setting +# off and back on, which is shared-mutable state: any other agent pushing +# or fetching during that window breaks, and a crash mid-flight leaves the +# machine altered for every repo, forever. +# +# This script authenticates over HTTPS using `gh`'s credential helper and +# rewrites the SSH URL to HTTPS, both scoped to this single `git push` +# invocation via `-c`. Nothing is written to any git config file, local or +# global, so there is nothing to restore and nothing to collide over. +set -euo pipefail + +usage() { + echo "usage: git-push-scoped [-C ] [--] [git-push-args...]" >&2 + exit 1 +} + +dir="." +while [[ $# -gt 0 ]]; do + case "$1" in + -C) + dir=$2 + shift 2 + ;; + --) + shift + break + ;; + -h | --help) + usage + ;; + *) + break + ;; + esac +done + +if ! command -v gh >/dev/null 2>&1; then + echo "git-push-scoped: gh is required for the scoped credential helper" >&2 + exit 1 +fi + +exec git -C "$dir" \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + -c url."https://github.com/".insteadOf="git@github.com:" \ + -c url."https://github.com/".insteadOf="ssh://git@github.com/" \ + push "$@" diff --git a/tests/integration/git-push-scoped.test.ts b/tests/integration/git-push-scoped.test.ts new file mode 100644 index 000000000..3c3c04a4f --- /dev/null +++ b/tests/integration/git-push-scoped.test.ts @@ -0,0 +1,134 @@ +/** + * `bin/git-push-scoped` must never write to global git config: not on a + * normal push, not when two pushes race, and not when one is killed + * mid-flight. Every test runs against an isolated HOME/GIT_CONFIG_GLOBAL + * so a bug here can never touch the real machine's config. + */ + +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +const SCRIPT = join(import.meta.dir, "../../bin/git-push-scoped"); + +let root: string; +let globalConfigPath: string; +let fakeBinDir: string; +let env: NodeJS.ProcessEnv; + +function run(args: string[], opts: { cwd?: string } = {}) { + return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + const child = spawn(SCRIPT, args, { cwd: opts.cwd ?? root, env, stdio: "ignore" }); + child.on("exit", (code, signal) => resolve({ code, signal })); + }); +} + +function initBareRemote(name: string): string { + const path = join(root, name); + mkdirSync(path); + spawnSync("git", ["init", "--bare", "-q", path]); + return path; +} + +// node:child_process.spawnSync is used only for synchronous test setup below. +import { spawnSync } from "node:child_process"; + +function initWorkingRepo(name: string, remotePath: string): string { + const path = join(root, name); + mkdirSync(path); + spawnSync("git", ["init", "-q", "-b", "main", path]); + spawnSync("git", ["-C", path, "config", "user.email", "test@example.com"]); + spawnSync("git", ["-C", path, "config", "user.name", "Test"]); + writeFileSync(join(path, "file.txt"), name); + spawnSync("git", ["-C", path, "add", "file.txt"]); + spawnSync("git", ["-C", path, "commit", "-q", "-m", "initial commit"]); + spawnSync("git", ["-C", path, "remote", "add", "origin", remotePath]); + return path; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "git-push-scoped-")); + + // Isolated global config: a known sentinel that must never change. + globalConfigPath = join(root, "global-gitconfig"); + writeFileSync(globalConfigPath, "[user]\n\tname = Sentinel\n\temail = sentinel@example.com\n"); + + // Stub `gh` on PATH so the script's `command -v gh` check passes without + // depending on a real GitHub CLI install or credentials. + fakeBinDir = join(root, "fakebin"); + mkdirSync(fakeBinDir); + const ghStub = join(fakeBinDir, "gh"); + writeFileSync(ghStub, "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(ghStub, 0o755); + + env = { + ...process.env, + HOME: root, + GIT_CONFIG_GLOBAL: globalConfigPath, + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${fakeBinDir}:${process.env.PATH}`, + }; +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("git-push-scoped", () => { + it("pushes successfully without writing to global git config", async () => { + const before = readFileSync(globalConfigPath, "utf8"); + const remote = initBareRemote("remote.git"); + const work = initWorkingRepo("work", remote); + + const { code } = await run(["origin", "main"], { cwd: work }); + + expect(code).toBe(0); + expect(readFileSync(globalConfigPath, "utf8")).toBe(before); + }); + + it("leaves global config untouched when two pushes race", async () => { + const before = readFileSync(globalConfigPath, "utf8"); + const remoteA = initBareRemote("remote-a.git"); + const remoteB = initBareRemote("remote-b.git"); + const workA = initWorkingRepo("work-a", remoteA); + const workB = initWorkingRepo("work-b", remoteB); + + const [resultA, resultB] = await Promise.all([ + run(["origin", "main"], { cwd: workA }), + run(["origin", "main"], { cwd: workB }), + ]); + + expect(resultA.code).toBe(0); + expect(resultB.code).toBe(0); + expect(readFileSync(globalConfigPath, "utf8")).toBe(before); + }); + + it("leaves global config untouched when killed mid-flight", async () => { + const before = readFileSync(globalConfigPath, "utf8"); + const remote = initBareRemote("remote-slow.git"); + const work = initWorkingRepo("work-slow", remote); + + // A pre-receive hook that sleeps gives us a reliable window to kill the + // push while it is in flight. + const hookPath = join(remote, "hooks", "pre-receive"); + writeFileSync(hookPath, "#!/usr/bin/env bash\nsleep 5\n"); + chmodSync(hookPath, 0o755); + + const child = spawn(SCRIPT, ["origin", "main"], { cwd: work, env, stdio: "ignore" }); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + child.on("exit", (code, signal) => resolve({ code, signal })); + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 300)); + child.kill("SIGKILL"); + const { signal } = await exited; + + expect(signal).toBe("SIGKILL"); + expect(readFileSync(globalConfigPath, "utf8")).toBe(before); + }); +}); From d7dad616adde14490f668d5798961da2ddc9135d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 08:13:24 -0700 Subject: [PATCH 2/2] Gate global git config mutation at the auto-shell policy layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 +- src/permission/auto-shell-policy.ts | 18 +++++++++ src/permission/classify-security.test.ts | 50 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6e3ab48d4..a2e7adeba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Follow the `style` skill's message format: plain-English summary, no `feat:`/`fi ## Pushing -**Never mutate global git config**, for any reason — not `git config --global`, not toggling `url.*.insteadOf`, not even temporarily with a plan to restore it. Global config is shared-mutable state across every agent and every repo on the machine; a crash or a second agent running concurrently turns a "temporary" toggle into a lasting outage or collision. This is the same hazard class as running `git stash` (also global, also banned). +**Never mutate git configuration outside the current repository**, for any reason and not even temporarily with a plan to restore it — whatever the command (`--global`, `--system`, `--edit`, `--file` pointed at a path outside the repo, reassigning or unsetting `GIT_CONFIG_GLOBAL`, or writing `~/.gitconfig` directly). That state is shared by every agent and every repo on the machine; a crash or a second agent running concurrently turns a "temporary" toggle into a lasting outage or collision. This is the same hazard class as running `git stash` (also global, also banned). Auto mode enforces this at the shell-policy layer (`git-global-config` in `src/permission/auto-shell-policy.ts`), which routes any such command to an operator ask instead of running it unattended — this instruction is the fallback for the cases the policy can't see, not the only line of defense. If SSH push fails because the shell can't reach the ssh-agent socket, use `bin/git-push-scoped` instead of touching config: diff --git a/src/permission/auto-shell-policy.ts b/src/permission/auto-shell-policy.ts index 7aceb1e3c..60e73d7ce 100644 --- a/src/permission/auto-shell-policy.ts +++ b/src/permission/auto-shell-policy.ts @@ -143,6 +143,24 @@ export const AUTO_SHELL_RULES: AutoShellRule[] = [ inCmd(String.raw`gcloud\s+auth\s+print-access-token\b`), ], }, + { + name: "git-global-config", + effect: "ask", + reason: + "This command mutates git configuration outside the current repository (--global, --system, an arbitrary --file, or GIT_CONFIG_GLOBAL). That state outlives this call and is shared by every other repo and agent on the machine, so it needs explicit operator approval and never runs unattended in auto mode. Use bin/git-push-scoped for an HTTPS push instead of rewriting global config.", + patterns: [ + // --global / --system write or read the machine-wide config files; + // --edit opens one in $EDITOR, which can write anything. + inCmd(String.raw`git\s+config\s+(?:--global|--system|--edit)\b`), + // --file points config at an arbitrary path, including ~/.gitconfig — + // ask rather than try to distinguish a repo-local target from that. + inCmd(String.raw`git\s+config\s+--file\b`), + // Unsetting GIT_CONFIG_GLOBAL falls back to the real ~/.gitconfig, the + // same as never having scoped it. (Reassigning it to a new path is + // already caught by the env-assignment rule above.) + inCmd(String.raw`unset\s+GIT_CONFIG_GLOBAL\b`), + ], + }, ]; export function matchAutoShellRule(command: string): AutoShellRule | undefined { diff --git a/src/permission/classify-security.test.ts b/src/permission/classify-security.test.ts index 1c3ce6ceb..50085a0d5 100644 --- a/src/permission/classify-security.test.ts +++ b/src/permission/classify-security.test.ts @@ -206,6 +206,56 @@ describe("credential-print shell commands force ask in auto mode", () => { }); }); +describe("git config mutation outside the repo forces ask in auto mode", () => { + test("--global write or read", () => { + expect(autoShellRuleForCall(shellCall("git config --global user.name foo"))?.name).toBe( + "git-global-config", + ); + expect(autoShellRuleForCall(shellCall("git config --global --get-regexp url."))?.name).toBe( + "git-global-config", + ); + }); + + test("--system", () => { + expect(autoShellRuleForCall(shellCall("git config --system user.name foo"))?.name).toBe( + "git-global-config", + ); + }); + + test("--edit opens an editor on a config file, which can write anything", () => { + expect(autoShellRuleForCall(shellCall("git config --global --edit"))?.name).toBe("git-global-config"); + expect(autoShellRuleForCall(shellCall("git config --edit"))?.name).toBe("git-global-config"); + }); + + test("--file to a path outside the workspace asks (via the outside-workspace rule)", () => { + expect(autoShellRuleForCall(shellCall("git config --file ~/.gitconfig user.name foo"))?.effect).toBe( + "ask", + ); + }); + + test("--file to a workspace-relative path still asks on its own", () => { + expect( + autoShellRuleForCall(shellCall("git config --file scratch.gitconfig user.name foo"))?.name, + ).toBe("git-global-config"); + }); + + test("unsetting GIT_CONFIG_GLOBAL falls back to the real ~/.gitconfig", () => { + expect(autoShellRuleForCall(shellCall("unset GIT_CONFIG_GLOBAL"))?.name).toBe("git-global-config"); + }); + + test("reassigning GIT_CONFIG_GLOBAL is caught by the general env-assignment rule", () => { + expect( + autoShellRuleForCall(shellCall("GIT_CONFIG_GLOBAL=/tmp/x git config --global foo bar"))?.name, + ).toBe("env-assignment"); + }); + + test("does not flag a plain repo-local config read or write", () => { + expect(autoShellRuleForCall(shellCall("git config user.name"))).toBeUndefined(); + expect(autoShellRuleForCall(shellCall("git config user.email me@example.com"))).toBeUndefined(); + expect(autoShellRuleForCall(shellCall("git config --local user.name foo"))).toBeUndefined(); + }); +}); + describe("sensitive-path shell commands require approval, not a hard deny", () => { test("secret-guard no longer hard-denies shell references to secret files", async () => { const middleware = secretGuardPlugin().middleware;