Skip to content

Commit fdb1604

Browse files
dbrosio3Pushgate Hook Harness
andauthored
fix git hook env contamination (#65)
* fix git hook env contamination * add ts docs * minor change --------- Co-authored-by: Pushgate Hook Harness <hook-harness@example.test>
1 parent 1e09ac5 commit fdb1604

16 files changed

Lines changed: 601 additions & 52 deletions

bin/pushgate.mjs

Lines changed: 57 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ai/providers/claude.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sanitizeGitLocalEnv } from "../../git/environment.js";
12
import { runCommand } from "../../process/run-command.js";
23
import { generateAiReviewOutputJsonSchema } from "../review-contract.js";
34
import { createCommandProviderAdapter } from "./command-provider-adapter.js";
@@ -320,7 +321,7 @@ async function isClaudeUnauthenticated(
320321
args: ["auth", "status"],
321322
command: "claude",
322323
cwd: repoRoot,
323-
env,
324+
env: sanitizeGitLocalEnv(env),
324325
});
325326

326327
return result.code === 1;

src/ai/providers/run-provider-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sanitizeGitLocalEnv } from "../../git/environment.js";
12
import {
23
isProcessCompletionOutcome,
34
runProcessOutcome,
@@ -37,7 +38,7 @@ export async function runProviderCommand(options: {
3738
args: options.args,
3839
command: options.command,
3940
cwd: options.cwd,
40-
env: options.env,
41+
env: sanitizeGitLocalEnv(options.env),
4142
outputCaptureLimit: options.outputCaptureLimit ?? null,
4243
outputTailLimit: options.outputTailLimit ?? DEFAULT_OUTPUT_TAIL_LIMIT,
4344
// Provider CLIs may exit before stdin fully drains; the process runner still

src/git/command.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type CommandResult,
44
type RunCommandOptions,
55
} from "../process/run-command.js";
6+
import { sanitizeGitLocalEnv } from "./environment.js";
67

78
export type GitCommandEncoding = "buffer" | "utf8";
89
export type GitCommandResult<Stdout extends Buffer | string = string> =
@@ -15,6 +16,7 @@ type GitCommandFailureResult = Pick<
1516
export interface GitCommandOptions {
1617
encoding?: GitCommandEncoding;
1718
env?: NodeJS.ProcessEnv;
19+
preserveGitConfigOverlay?: boolean;
1820
}
1921

2022
export class GitCommandError extends Error {
@@ -51,7 +53,9 @@ export function runGit(
5153
args,
5254
command: "git",
5355
cwd: repoRoot,
54-
env: options.env,
56+
env: sanitizeGitLocalEnv(options.env ?? process.env, {
57+
preserveGitConfigOverlay: options.preserveGitConfigOverlay,
58+
}),
5559
};
5660

5761
if (options.encoding === "buffer") {

src/git/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ export async function readGitBooleanConfig(
1111
repoRoot: string,
1212
key: string,
1313
env: NodeJS.ProcessEnv = process.env,
14+
options: {
15+
preserveGitConfigOverlay?: boolean;
16+
} = {},
1417
): Promise<boolean> {
1518
let result: Awaited<ReturnType<typeof runGit>>;
1619

1720
try {
1821
result = await runGit(repoRoot, ["config", "--bool", "--get", key], {
1922
env,
23+
preserveGitConfigOverlay: options.preserveGitConfigOverlay,
2024
});
2125
} catch (error) {
2226
throw new GitConfigError(

src/git/environment.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const GIT_LOCAL_ENV_VARS = new Set([
2+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
3+
"GIT_COMMON_DIR",
4+
"GIT_CONFIG",
5+
"GIT_CONFIG_COUNT",
6+
"GIT_CONFIG_PARAMETERS",
7+
"GIT_DIR",
8+
"GIT_GRAFT_FILE",
9+
"GIT_IMPLICIT_WORK_TREE",
10+
"GIT_INDEX_FILE",
11+
"GIT_NO_REPLACE_OBJECTS",
12+
"GIT_OBJECT_DIRECTORY",
13+
"GIT_PREFIX",
14+
"GIT_REPLACE_REF_BASE",
15+
"GIT_SHALLOW_FILE",
16+
"GIT_WORK_TREE",
17+
]);
18+
19+
const GIT_CONFIG_PAIR_ENV_VAR = /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/;
20+
21+
export interface SanitizeGitLocalEnvOptions {
22+
/**
23+
* Keep `git -c` config passed through Git's environment protocol.
24+
* Use only when intentionally reading caller-supplied Git config overlays.
25+
*/
26+
preserveGitConfigOverlay?: boolean;
27+
}
28+
29+
/**
30+
* Removes Git hook-local repository bindings from an environment copy.
31+
*
32+
* Git hooks can run with `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and
33+
* related variables pointing at the repository being pushed. If Pushgate passes
34+
* those variables into tools, plugins, providers, or explicit-`cwd` Git helpers,
35+
* nested Git commands may operate on the hook repo instead of their own cwd.
36+
*/
37+
export function sanitizeGitLocalEnv(
38+
env: NodeJS.ProcessEnv,
39+
options: SanitizeGitLocalEnvOptions = {},
40+
): NodeJS.ProcessEnv {
41+
const sanitized: NodeJS.ProcessEnv = {};
42+
43+
for (const [key, value] of Object.entries(env)) {
44+
if (shouldRemoveGitEnvVar(key, options)) {
45+
continue;
46+
}
47+
48+
if (value !== undefined) {
49+
sanitized[key] = value;
50+
}
51+
}
52+
53+
return sanitized;
54+
}
55+
56+
/** Returns `true` for repository-local Git environment variables. */
57+
export function isGitLocalEnvVar(key: string): boolean {
58+
return GIT_LOCAL_ENV_VARS.has(key) || GIT_CONFIG_PAIR_ENV_VAR.test(key);
59+
}
60+
61+
function shouldRemoveGitEnvVar(
62+
key: string,
63+
options: SanitizeGitLocalEnvOptions,
64+
): boolean {
65+
if (options.preserveGitConfigOverlay && isGitConfigOverlayEnvVar(key)) {
66+
return false;
67+
}
68+
69+
return isGitLocalEnvVar(key);
70+
}
71+
72+
function isGitConfigOverlayEnvVar(key: string): boolean {
73+
return (
74+
key === "GIT_CONFIG_COUNT" ||
75+
key === "GIT_CONFIG_PARAMETERS" ||
76+
GIT_CONFIG_PAIR_ENV_VAR.test(key)
77+
);
78+
}

src/runner/plugins/gitleaks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44

55
import type { GitleaksPluginConfig } from "../../config/index.js";
6+
import { sanitizeGitLocalEnv } from "../../git/environment.js";
67
import type { ChangedFileResolution } from "../../path-policy/index.js";
78
import {
89
formatProcessFailure,
@@ -47,7 +48,7 @@ export async function runGitleaksPlugin(
4748
args: buildGitleaksArgs(plugin, changedFileResolution, repoRoot, reportPath),
4849
command: plugin.command,
4950
cwd: repoRoot,
50-
env,
51+
env: sanitizeGitLocalEnv(env),
5152
timeoutSeconds: plugin.timeout_seconds,
5253
});
5354

src/runner/tool-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ToolConfig } from "../config/index.js";
2+
import { sanitizeGitLocalEnv } from "../git/environment.js";
23
import {
34
formatProcessFailure,
45
runProcessOutcome,
@@ -32,7 +33,7 @@ export async function runToolCommand(
3233
args,
3334
command: executable,
3435
cwd: repoRoot,
35-
env,
36+
env: sanitizeGitLocalEnv(env),
3637
timeoutSeconds: tool.timeout_seconds,
3738
});
3839

src/skip-controls.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ async function readSkipBooleanConfig(
116116
key: string,
117117
): Promise<boolean> {
118118
try {
119-
return await readGitBooleanConfig(repoRoot, key, env);
119+
return await readGitBooleanConfig(repoRoot, key, env, {
120+
preserveGitConfigOverlay: true,
121+
});
120122
} catch (error) {
121123
if (error instanceof GitConfigError) {
122124
throw new SkipControlError(error.message);

0 commit comments

Comments
 (0)