Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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:

```bash
bin/git-push-scoped origin <branch>
```

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.
Expand Down
52 changes: 52 additions & 0 deletions bin/git-push-scoped
Original file line number Diff line number Diff line change
@@ -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 <dir>] [--] [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 "$@"
18 changes: 18 additions & 0 deletions src/permission/auto-shell-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions src/permission/classify-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions tests/integration/git-push-scoped.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading