Skip to content

Commit eadc721

Browse files
committed
fix(approval): read identity from git config instead of env var
GIT_USER_NAME is set during the entrypoint boot sequence and written into git config (--system user.name), but the env var is not available to the approval process at runtime. This caused the ownership exemption check to always fail (gitUserName was undefined). Read from `git config user.name` instead, which is reliably set during boot and available to all processes in the container.
1 parent e7ac13d commit eadc721

2 files changed

Lines changed: 121 additions & 70 deletions

File tree

approval/__tests__/ownership.test.ts

Lines changed: 99 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,27 @@ describe("parseRemoteFromPushCommand", () => {
1111
});
1212

1313
test("extracts remote from 'git push my-fork feature'", () => {
14-
expect(parseRemoteFromPushCommand("git push my-fork feature")).toBe("my-fork");
14+
expect(parseRemoteFromPushCommand("git push my-fork feature")).toBe(
15+
"my-fork",
16+
);
1517
});
1618

1719
test("skips flags: 'git push --force origin main'", () => {
18-
expect(parseRemoteFromPushCommand("git push --force origin main")).toBe("origin");
20+
expect(parseRemoteFromPushCommand("git push --force origin main")).toBe(
21+
"origin",
22+
);
1923
});
2024

2125
test("skips flags: 'git push -u origin feature'", () => {
22-
expect(parseRemoteFromPushCommand("git push -u origin feature")).toBe("origin");
26+
expect(parseRemoteFromPushCommand("git push -u origin feature")).toBe(
27+
"origin",
28+
);
2329
});
2430

2531
test("skips compound flags: 'git push --set-upstream origin main'", () => {
26-
expect(parseRemoteFromPushCommand("git push --set-upstream origin main")).toBe("origin");
32+
expect(
33+
parseRemoteFromPushCommand("git push --set-upstream origin main"),
34+
).toBe("origin");
2735
});
2836

2937
test("returns null for bare 'git push' (can't assume origin)", () => {
@@ -35,15 +43,23 @@ describe("parseRemoteFromPushCommand", () => {
3543
});
3644

3745
test("returns null when value-consuming flag -o is present", () => {
38-
expect(parseRemoteFromPushCommand("git push -o ci.skip origin main")).toBeNull();
46+
expect(
47+
parseRemoteFromPushCommand("git push -o ci.skip origin main"),
48+
).toBeNull();
3949
});
4050

4151
test("returns null when --push-option is present", () => {
42-
expect(parseRemoteFromPushCommand("git push --push-option ci.skip origin main")).toBeNull();
52+
expect(
53+
parseRemoteFromPushCommand("git push --push-option ci.skip origin main"),
54+
).toBeNull();
4355
});
4456

4557
test("returns null when --repo is present", () => {
46-
expect(parseRemoteFromPushCommand("git push --repo=https://example.com origin main")).toBeNull();
58+
expect(
59+
parseRemoteFromPushCommand(
60+
"git push --repo=https://example.com origin main",
61+
),
62+
).toBeNull();
4763
});
4864

4965
test("returns null for non-git-push command", () => {
@@ -57,7 +73,9 @@ describe("parseRemoteFromPushCommand", () => {
5773

5874
describe("extractGitHubOwner", () => {
5975
test("extracts owner from HTTPS URL", () => {
60-
expect(extractGitHubOwner("https://github.com/alice/repo.git")).toBe("alice");
76+
expect(extractGitHubOwner("https://github.com/alice/repo.git")).toBe(
77+
"alice",
78+
);
6179
});
6280

6381
test("extracts owner from HTTPS URL without .git", () => {
@@ -86,146 +104,162 @@ describe("extractGitHubOwner", () => {
86104
});
87105

88106
describe("isOwnedRemotePush", () => {
89-
let originalEnv: string | undefined;
90107
let originalSpawn: typeof Bun.spawn;
91108

92109
beforeEach(() => {
93-
originalEnv = process.env.GIT_USER_NAME;
94110
originalSpawn = Bun.spawn;
95111
});
96112

97113
afterEach(() => {
98-
if (originalEnv === undefined) {
99-
delete process.env.GIT_USER_NAME;
100-
} else {
101-
process.env.GIT_USER_NAME = originalEnv;
102-
}
103114
Bun.spawn = originalSpawn;
104115
});
105116

106-
let lastSpawnArgs: string[] = [];
107-
108-
function mockGitRemote(url: string, exitCode = 0) {
117+
let spawnCalls: string[][] = [];
118+
119+
/**
120+
* Mock Bun.spawn to handle two sequential calls:
121+
* 1. git config user.name → returns userName
122+
* 2. git remote get-url --push <remote> → returns remoteUrl
123+
*
124+
* configExitCode / remoteExitCode let individual tests simulate failures.
125+
*/
126+
function mockGitSpawn(
127+
userName: string,
128+
remoteUrl: string,
129+
{ configExitCode = 0, remoteExitCode = 0 } = {},
130+
) {
131+
spawnCalls = [];
132+
let callIndex = 0;
109133
// @ts-expect-error — partial mock of Bun.spawn for testing
110134
Bun.spawn = (args: string[]) => {
111-
lastSpawnArgs = args;
135+
spawnCalls.push(args);
136+
const idx = callIndex++;
137+
const isConfigCall = idx === 0;
138+
const output = isConfigCall ? userName : remoteUrl;
139+
const exit = isConfigCall ? configExitCode : remoteExitCode;
112140
return {
113141
stdout: new ReadableStream({
114142
start(controller) {
115-
controller.enqueue(new TextEncoder().encode(url + "\n"));
143+
controller.enqueue(new TextEncoder().encode(output + "\n"));
116144
controller.close();
117145
},
118146
}),
119147
stderr: new ReadableStream({
120-
start(controller) { controller.close(); },
148+
start(controller) {
149+
controller.close();
150+
},
121151
}),
122-
exited: Promise.resolve(exitCode),
152+
exited: Promise.resolve(exit),
123153
};
124154
};
125155
}
126156

127157
test("allows push to owned remote", async () => {
128-
process.env.GIT_USER_NAME = "alice";
129-
mockGitRemote("https://github.com/alice/repo.git");
158+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
130159
expect(await isOwnedRemotePush("git push origin main")).toBe(true);
131-
// Verify spawn was called with --push and correct remote
132-
expect(lastSpawnArgs).toEqual(["git", "remote", "get-url", "--push", "origin"]);
160+
// Verify both spawn calls
161+
expect(spawnCalls[0]).toEqual(["git", "config", "user.name"]);
162+
expect(spawnCalls[1]).toEqual([
163+
"git",
164+
"remote",
165+
"get-url",
166+
"--push",
167+
"origin",
168+
]);
133169
});
134170

135171
test("denies push to non-owned remote", async () => {
136-
process.env.GIT_USER_NAME = "alice";
137-
mockGitRemote("https://github.com/upstream-org/repo.git");
172+
mockGitSpawn("alice", "https://github.com/upstream-org/repo.git");
138173
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
139174
});
140175

141176
test("allows push to owned non-origin remote", async () => {
142-
process.env.GIT_USER_NAME = "alice";
143-
mockGitRemote("https://github.com/alice/repo.git");
177+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
144178
expect(await isOwnedRemotePush("git push my-fork feature")).toBe(true);
145-
// Verify spawn was called with the parsed remote name, not hardcoded "origin"
146-
expect(lastSpawnArgs).toEqual(["git", "remote", "get-url", "--push", "my-fork"]);
179+
expect(spawnCalls[1]).toEqual([
180+
"git",
181+
"remote",
182+
"get-url",
183+
"--push",
184+
"my-fork",
185+
]);
147186
});
148187

149188
test("case-insensitive username match", async () => {
150-
process.env.GIT_USER_NAME = "Alice";
151-
mockGitRemote("https://github.com/alice/repo.git");
189+
mockGitSpawn("Alice", "https://github.com/alice/repo.git");
152190
expect(await isOwnedRemotePush("git push origin main")).toBe(true);
153191
});
154192

155193
test("allows force push to owned remote", async () => {
156-
process.env.GIT_USER_NAME = "alice";
157-
mockGitRemote("https://github.com/alice/repo.git");
194+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
158195
expect(await isOwnedRemotePush("git push --force origin main")).toBe(true);
159196
});
160197

161198
test("blocks --delete even on owned remote", async () => {
162-
process.env.GIT_USER_NAME = "alice";
163-
mockGitRemote("https://github.com/alice/repo.git");
164-
expect(await isOwnedRemotePush("git push --delete origin feature")).toBe(false);
199+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
200+
expect(await isOwnedRemotePush("git push --delete origin feature")).toBe(
201+
false,
202+
);
165203
});
166204

167205
test("blocks -d even on owned remote", async () => {
168-
process.env.GIT_USER_NAME = "alice";
169-
mockGitRemote("https://github.com/alice/repo.git");
206+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
170207
expect(await isOwnedRemotePush("git push -d origin feature")).toBe(false);
171208
});
172209

173-
test("returns false when GIT_USER_NAME is unset", async () => {
174-
delete process.env.GIT_USER_NAME;
175-
mockGitRemote("https://github.com/alice/repo.git");
210+
test("returns false when git config user.name fails", async () => {
211+
mockGitSpawn("", "https://github.com/alice/repo.git", {
212+
configExitCode: 1,
213+
});
214+
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
215+
});
216+
217+
test("returns false when git config user.name is empty", async () => {
218+
mockGitSpawn("", "https://github.com/alice/repo.git");
176219
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
177220
});
178221

179-
test("returns false when git command fails", async () => {
180-
process.env.GIT_USER_NAME = "alice";
181-
mockGitRemote("", 128);
222+
test("returns false when git remote get-url fails", async () => {
223+
mockGitSpawn("alice", "", { remoteExitCode: 128 });
182224
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
183225
});
184226

185227
test("returns false for non-GitHub remote", async () => {
186-
process.env.GIT_USER_NAME = "alice";
187-
mockGitRemote("https://gitlab.com/alice/repo.git");
228+
mockGitSpawn("alice", "https://gitlab.com/alice/repo.git");
188229
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
189230
});
190231

191232
test("returns false for bare 'git push' (can't assume origin)", async () => {
192-
process.env.GIT_USER_NAME = "alice";
193-
mockGitRemote("https://github.com/alice/repo.git");
233+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
194234
expect(await isOwnedRemotePush("git push")).toBe(false);
195235
});
196236

197237
test("returns false when value-consuming flags are present", async () => {
198-
process.env.GIT_USER_NAME = "alice";
199-
mockGitRemote("https://github.com/alice/repo.git");
200-
expect(await isOwnedRemotePush("git push -o ci.skip origin main")).toBe(false);
238+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
239+
expect(await isOwnedRemotePush("git push -o ci.skip origin main")).toBe(
240+
false,
241+
);
201242
});
202243

203244
test("returns false for non-push commands", async () => {
204-
process.env.GIT_USER_NAME = "alice";
205-
mockGitRemote("https://github.com/alice/repo.git");
245+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
206246
expect(await isOwnedRemotePush("git status")).toBe(false);
207247
});
208248

209249
test("handles SSH remote URL", async () => {
210-
process.env.GIT_USER_NAME = "alice";
211-
mockGitRemote("git@github.com:alice/repo.git");
250+
mockGitSpawn("alice", "git@github.com:alice/repo.git");
212251
expect(await isOwnedRemotePush("git push origin main")).toBe(true);
213252
});
214253

215254
test("skips -u flag and finds remote", async () => {
216-
process.env.GIT_USER_NAME = "alice";
217-
mockGitRemote("https://github.com/alice/repo.git");
255+
mockGitSpawn("alice", "https://github.com/alice/repo.git");
218256
expect(await isOwnedRemotePush("git push -u origin feature")).toBe(true);
219257
});
220258

221259
// SECURITY: pushurl vs url divergence — ownership check must use pushurl
222260
// because that is the URL git actually pushes to.
223261
test("uses push URL for ownership check (pushurl security invariant)", async () => {
224-
process.env.GIT_USER_NAME = "alice";
225-
// Simulate a repo where pushurl differs from url.
226-
// The mock returns what --push would return (the pushurl).
227-
// If pushurl points to a non-owned remote, the check must fail.
228-
mockGitRemote("https://github.com/victim-org/repo.git");
262+
mockGitSpawn("alice", "https://github.com/victim-org/repo.git");
229263
expect(await isOwnedRemotePush("git push origin main")).toBe(false);
230264
});
231265
});

approval/check-command.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const GIT_PUSH_RE = /^git\s+push\b/;
3333

3434
// Flags that consume the next token as a value (e.g., -o <value>, --repo <value>).
3535
// If present, we can't reliably parse the remote name, so we refuse the exemption.
36-
const VALUE_CONSUMING_FLAGS = /^(-o|--push-option|--repo|--receive-pack|--exec|--signed)$/;
36+
const VALUE_CONSUMING_FLAGS =
37+
/^(-o|--push-option|--repo|--receive-pack|--exec|--signed)$/;
3738

3839
/**
3940
* Parse the target remote name from a git push command string.
@@ -92,7 +93,7 @@ const HAS_DELETE_FLAG = /\s--delete\b|\s-[a-zA-Z]*d/;
9293
* Check if a git push command targets a remote owned by GIT_USER_NAME.
9394
* Returns true if the push should be exempted from block rules.
9495
*
95-
* Fail-safe: returns false on any error (missing env var, git failure,
96+
* Fail-safe: returns false on any error (missing git config, git failure,
9697
* unparseable URL, non-GitHub host, --delete flag present).
9798
*/
9899
export async function isOwnedRemotePush(command: string): Promise<boolean> {
@@ -102,10 +103,26 @@ export async function isOwnedRemotePush(command: string): Promise<boolean> {
102103
// --delete pushes are never exempted, even to owned remotes
103104
if (HAS_DELETE_FLAG.test(command)) return false;
104105

105-
const gitUserName = process.env.GIT_USER_NAME;
106-
if (!gitUserName) return false;
107-
108106
try {
107+
// Read identity from git config (set during entrypoint from GIT_USER_NAME).
108+
// We don't use process.env.GIT_USER_NAME because the env var may not be
109+
// available to the approval process at runtime.
110+
const configProc = Bun.spawn(["git", "config", "user.name"], {
111+
stdout: "pipe",
112+
stderr: "pipe",
113+
});
114+
115+
const [configStdout, , configExitCode] = await Promise.all([
116+
new Response(configProc.stdout).text(),
117+
new Response(configProc.stderr).text(),
118+
configProc.exited,
119+
]);
120+
121+
if (configExitCode !== 0) return false;
122+
123+
const gitUserName = configStdout.trim();
124+
if (!gitUserName) return false;
125+
109126
// SECURITY: --push returns the URL git actually uses for push operations.
110127
// If pushurl is configured, --push returns pushurl (not url).
111128
// This ensures we check ownership against the same URL git will push to,

0 commit comments

Comments
 (0)