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
65 changes: 57 additions & 8 deletions bin/pushgate.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/ai/providers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { sanitizeGitLocalEnv } from "../../git/environment.js";
import { runCommand } from "../../process/run-command.js";
import { generateAiReviewOutputJsonSchema } from "../review-contract.js";
import { createCommandProviderAdapter } from "./command-provider-adapter.js";
Expand Down Expand Up @@ -320,7 +321,7 @@ async function isClaudeUnauthenticated(
args: ["auth", "status"],
command: "claude",
cwd: repoRoot,
env,
env: sanitizeGitLocalEnv(env),
});

return result.code === 1;
Expand Down
3 changes: 2 additions & 1 deletion src/ai/providers/run-provider-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { sanitizeGitLocalEnv } from "../../git/environment.js";
import {
isProcessCompletionOutcome,
runProcessOutcome,
Expand Down Expand Up @@ -37,7 +38,7 @@ export async function runProviderCommand(options: {
args: options.args,
command: options.command,
cwd: options.cwd,
env: options.env,
env: sanitizeGitLocalEnv(options.env),
outputCaptureLimit: options.outputCaptureLimit ?? null,
outputTailLimit: options.outputTailLimit ?? DEFAULT_OUTPUT_TAIL_LIMIT,
// Provider CLIs may exit before stdin fully drains; the process runner still
Expand Down
6 changes: 5 additions & 1 deletion src/git/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type CommandResult,
type RunCommandOptions,
} from "../process/run-command.js";
import { sanitizeGitLocalEnv } from "./environment.js";

export type GitCommandEncoding = "buffer" | "utf8";
export type GitCommandResult<Stdout extends Buffer | string = string> =
Expand All @@ -15,6 +16,7 @@ type GitCommandFailureResult = Pick<
export interface GitCommandOptions {
encoding?: GitCommandEncoding;
env?: NodeJS.ProcessEnv;
preserveGitConfigOverlay?: boolean;
}

export class GitCommandError extends Error {
Expand Down Expand Up @@ -51,7 +53,9 @@ export function runGit(
args,
command: "git",
cwd: repoRoot,
env: options.env,
env: sanitizeGitLocalEnv(options.env ?? process.env, {
preserveGitConfigOverlay: options.preserveGitConfigOverlay,
}),
};

if (options.encoding === "buffer") {
Expand Down
4 changes: 4 additions & 0 deletions src/git/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ export async function readGitBooleanConfig(
repoRoot: string,
key: string,
env: NodeJS.ProcessEnv = process.env,
options: {
preserveGitConfigOverlay?: boolean;
} = {},
): Promise<boolean> {
let result: Awaited<ReturnType<typeof runGit>>;

try {
result = await runGit(repoRoot, ["config", "--bool", "--get", key], {
env,
preserveGitConfigOverlay: options.preserveGitConfigOverlay,
});
} catch (error) {
throw new GitConfigError(
Expand Down
78 changes: 78 additions & 0 deletions src/git/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const GIT_LOCAL_ENV_VARS = new Set([
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_CONFIG",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_PARAMETERS",
"GIT_DIR",
"GIT_GRAFT_FILE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_OBJECT_DIRECTORY",
"GIT_PREFIX",
"GIT_REPLACE_REF_BASE",
"GIT_SHALLOW_FILE",
"GIT_WORK_TREE",
]);

const GIT_CONFIG_PAIR_ENV_VAR = /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/;

export interface SanitizeGitLocalEnvOptions {
/**
* Keep `git -c` config passed through Git's environment protocol.
* Use only when intentionally reading caller-supplied Git config overlays.
*/
preserveGitConfigOverlay?: boolean;
}

/**
* Removes Git hook-local repository bindings from an environment copy.
*
* Git hooks can run with `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and
* related variables pointing at the repository being pushed. If Pushgate passes
* those variables into tools, plugins, providers, or explicit-`cwd` Git helpers,
* nested Git commands may operate on the hook repo instead of their own cwd.
*/
export function sanitizeGitLocalEnv(
env: NodeJS.ProcessEnv,
options: SanitizeGitLocalEnvOptions = {},
): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = {};

for (const [key, value] of Object.entries(env)) {
if (shouldRemoveGitEnvVar(key, options)) {
continue;
}

if (value !== undefined) {
sanitized[key] = value;
}
}

return sanitized;
}

/** Returns `true` for repository-local Git environment variables. */
export function isGitLocalEnvVar(key: string): boolean {
return GIT_LOCAL_ENV_VARS.has(key) || GIT_CONFIG_PAIR_ENV_VAR.test(key);
}

function shouldRemoveGitEnvVar(
key: string,
options: SanitizeGitLocalEnvOptions,
): boolean {
if (options.preserveGitConfigOverlay && isGitConfigOverlayEnvVar(key)) {
return false;
}

return isGitLocalEnvVar(key);
}

function isGitConfigOverlayEnvVar(key: string): boolean {
return (
key === "GIT_CONFIG_COUNT" ||
key === "GIT_CONFIG_PARAMETERS" ||
GIT_CONFIG_PAIR_ENV_VAR.test(key)
);
}
3 changes: 2 additions & 1 deletion src/runner/plugins/gitleaks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import type { GitleaksPluginConfig } from "../../config/index.js";
import { sanitizeGitLocalEnv } from "../../git/environment.js";
import type { ChangedFileResolution } from "../../path-policy/index.js";
import {
formatProcessFailure,
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function runGitleaksPlugin(
args: buildGitleaksArgs(plugin, changedFileResolution, repoRoot, reportPath),
command: plugin.command,
cwd: repoRoot,
env,
env: sanitizeGitLocalEnv(env),
timeoutSeconds: plugin.timeout_seconds,
});

Expand Down
3 changes: 2 additions & 1 deletion src/runner/tool-command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ToolConfig } from "../config/index.js";
import { sanitizeGitLocalEnv } from "../git/environment.js";
import {
formatProcessFailure,
runProcessOutcome,
Expand Down Expand Up @@ -32,7 +33,7 @@ export async function runToolCommand(
args,
command: executable,
cwd: repoRoot,
env,
env: sanitizeGitLocalEnv(env),
timeoutSeconds: tool.timeout_seconds,
});

Expand Down
4 changes: 3 additions & 1 deletion src/skip-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ async function readSkipBooleanConfig(
key: string,
): Promise<boolean> {
try {
return await readGitBooleanConfig(repoRoot, key, env);
return await readGitBooleanConfig(repoRoot, key, env, {
preserveGitConfigOverlay: true,
});
} catch (error) {
if (error instanceof GitConfigError) {
throw new SkipControlError(error.message);
Expand Down
Loading
Loading