Skip to content

Commit d7dad61

Browse files
committed
Gate global git config mutation at the auto-shell policy layer
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.
1 parent 47d89b5 commit d7dad61

3 files changed

Lines changed: 69 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Follow the `style` skill's message format: plain-English summary, no `feat:`/`fi
5555

5656
## Pushing
5757

58-
**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).
58+
**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.
5959

6060
If SSH push fails because the shell can't reach the ssh-agent socket, use `bin/git-push-scoped` instead of touching config:
6161

src/permission/auto-shell-policy.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,24 @@ export const AUTO_SHELL_RULES: AutoShellRule[] = [
143143
inCmd(String.raw`gcloud\s+auth\s+print-access-token\b`),
144144
],
145145
},
146+
{
147+
name: "git-global-config",
148+
effect: "ask",
149+
reason:
150+
"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.",
151+
patterns: [
152+
// --global / --system write or read the machine-wide config files;
153+
// --edit opens one in $EDITOR, which can write anything.
154+
inCmd(String.raw`git\s+config\s+(?:--global|--system|--edit)\b`),
155+
// --file points config at an arbitrary path, including ~/.gitconfig —
156+
// ask rather than try to distinguish a repo-local target from that.
157+
inCmd(String.raw`git\s+config\s+--file\b`),
158+
// Unsetting GIT_CONFIG_GLOBAL falls back to the real ~/.gitconfig, the
159+
// same as never having scoped it. (Reassigning it to a new path is
160+
// already caught by the env-assignment rule above.)
161+
inCmd(String.raw`unset\s+GIT_CONFIG_GLOBAL\b`),
162+
],
163+
},
146164
];
147165

148166
export function matchAutoShellRule(command: string): AutoShellRule | undefined {

src/permission/classify-security.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,56 @@ describe("credential-print shell commands force ask in auto mode", () => {
206206
});
207207
});
208208

209+
describe("git config mutation outside the repo forces ask in auto mode", () => {
210+
test("--global write or read", () => {
211+
expect(autoShellRuleForCall(shellCall("git config --global user.name foo"))?.name).toBe(
212+
"git-global-config",
213+
);
214+
expect(autoShellRuleForCall(shellCall("git config --global --get-regexp url."))?.name).toBe(
215+
"git-global-config",
216+
);
217+
});
218+
219+
test("--system", () => {
220+
expect(autoShellRuleForCall(shellCall("git config --system user.name foo"))?.name).toBe(
221+
"git-global-config",
222+
);
223+
});
224+
225+
test("--edit opens an editor on a config file, which can write anything", () => {
226+
expect(autoShellRuleForCall(shellCall("git config --global --edit"))?.name).toBe("git-global-config");
227+
expect(autoShellRuleForCall(shellCall("git config --edit"))?.name).toBe("git-global-config");
228+
});
229+
230+
test("--file to a path outside the workspace asks (via the outside-workspace rule)", () => {
231+
expect(autoShellRuleForCall(shellCall("git config --file ~/.gitconfig user.name foo"))?.effect).toBe(
232+
"ask",
233+
);
234+
});
235+
236+
test("--file to a workspace-relative path still asks on its own", () => {
237+
expect(
238+
autoShellRuleForCall(shellCall("git config --file scratch.gitconfig user.name foo"))?.name,
239+
).toBe("git-global-config");
240+
});
241+
242+
test("unsetting GIT_CONFIG_GLOBAL falls back to the real ~/.gitconfig", () => {
243+
expect(autoShellRuleForCall(shellCall("unset GIT_CONFIG_GLOBAL"))?.name).toBe("git-global-config");
244+
});
245+
246+
test("reassigning GIT_CONFIG_GLOBAL is caught by the general env-assignment rule", () => {
247+
expect(
248+
autoShellRuleForCall(shellCall("GIT_CONFIG_GLOBAL=/tmp/x git config --global foo bar"))?.name,
249+
).toBe("env-assignment");
250+
});
251+
252+
test("does not flag a plain repo-local config read or write", () => {
253+
expect(autoShellRuleForCall(shellCall("git config user.name"))).toBeUndefined();
254+
expect(autoShellRuleForCall(shellCall("git config user.email me@example.com"))).toBeUndefined();
255+
expect(autoShellRuleForCall(shellCall("git config --local user.name foo"))).toBeUndefined();
256+
});
257+
});
258+
209259
describe("sensitive-path shell commands require approval, not a hard deny", () => {
210260
test("secret-guard no longer hard-denies shell references to secret files", async () => {
211261
const middleware = secretGuardPlugin().middleware;

0 commit comments

Comments
 (0)