diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e01bfd0b..91f5eda4 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -37,6 +37,7 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) from rank_preview import DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, preview_for +from workbench_constants import trusted_git_executable EXCLUDED_DIRS = { ".cache", @@ -488,12 +489,21 @@ def bind_repo_scopes(args: argparse.Namespace) -> None: def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]: + git = trusted_git_executable(repo) + if git is None: + raise SystemExit("Git is unavailable on the trusted executable path.") result = subprocess.run( [ - "git", + git, + "-c", + "core.fsmonitor=false", + "-c", + "core.hooksPath=/dev/null", "-C", str(repo), "diff", + "--no-ext-diff", + "--no-textconv", "--name-status", "-z", "--diff-filter=ACMRD", diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 4ddd91f3..39ad03dc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -1,6 +1,8 @@ """Shared constants for the Codex Security workbench.""" import argparse +import os +from pathlib import Path MODES = ("diff", "standard", "deep") DIFF_TARGET_KINDS = ("working_tree", "commit", "range") @@ -73,6 +75,26 @@ EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +def trusted_git_executable(protected_root: Path | None = None) -> str | None: + executable = os.environ.get("CODEX_SECURITY_GIT") + if not executable: + return None + candidate = Path(executable) + if not candidate.is_absolute(): + raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + try: + canonical = candidate.resolve(strict=True) + except OSError as exc: + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from exc + if not canonical.is_file() or not os.access(canonical, os.X_OK): + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") + if protected_root is not None: + root = protected_root.resolve() + if canonical == root or root in canonical.parents: + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") + return str(canonical) + + def main() -> None: argparse.ArgumentParser(description=__doc__).parse_args() diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index c39c9635..3b6bcc57 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -16,7 +16,7 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) from filesystem_identity import stored_filesystem_identity_matches -from workbench_constants import GIT_REPOSITORY_ENVIRONMENT +from workbench_constants import GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable def git_output( @@ -54,10 +54,22 @@ def git_command( environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" # Repository-local config is untrusted; fsmonitor may name an executable hook. - command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)] + executable = trusted_git_executable(target) + command = [ + executable or "git", + "-c", + "core.fsmonitor=false", + "-c", + "core.hooksPath=/dev/null", + "-C", + str(target), + ] if git_dir is not None and work_tree is not None: command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] + if executable is None: + empty_output = "" if text else b"" + return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) try: return subprocess.run( full_command, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index d2381d3d..856c46e5 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -85,6 +85,7 @@ import { validatedGitEnvironment, validateMode, } from "./targets.js"; +import { resolveTrustedExecutable } from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -465,6 +466,15 @@ export class CodexSecurity { protectedRoot, signal, }); + const pluginEnvironment = selectedScanEnvironment( + runtime.environment, + options.auth, + ); + const git = await resolveTrustedExecutable( + "git", + pluginEnvironment, + protectedRoot, + ); checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -575,9 +585,11 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment(runtime.environment, options.auth), + ...pluginEnvironment, CODEX_SECURITY_STATE_DIR: stateDirectory, }, + git, + protectedRoot, signal, failureMessage: "Could not save the Codex Security scan", }; @@ -729,9 +741,8 @@ export class CodexSecurity { const environment = { ...pluginExecutionEnvironment( python, - withoutCodexHome( - selectedScanEnvironment(runtime.environment, options.auth), - ), + withoutCodexHome(pluginEnvironment), + git, ), CODEX_HOME: runtime.codexHome, ...runtimePaths, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 93b98c5e..14659dec 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -43,6 +43,7 @@ import { } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; +import type { TrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -92,6 +93,8 @@ export interface WorkbenchCommandOptions { python: string; pluginRoot: string; environment: ProcessEnvironment; + git?: TrustedExecutable | null; + protectedRoot?: string; signal?: AbortSignal; failureMessage?: string; } @@ -456,6 +459,19 @@ export async function runWorkbench( options: WorkbenchCommandOptions, args: readonly string[], ): Promise { + const git = + options.git === undefined + ? await resolveTrustedExecutable( + "git", + options.environment, + options.protectedRoot ?? process.cwd(), + ) + : options.git; + const environment = pluginExecutionEnvironment( + options.python, + options.environment, + git, + ); let stdout: string; try { ({ stdout } = await execFile( @@ -468,7 +484,7 @@ export async function runWorkbench( ], { env: Object.fromEntries( - Object.entries(options.environment).filter( + Object.entries(environment).filter( ([name]) => name.toUpperCase() !== "OPENAI_API_KEY" && name.toUpperCase() !== "CODEX_API_KEY", @@ -1532,8 +1548,21 @@ export async function resolvePluginPython( export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, + git?: TrustedExecutable | null, ): ProcessEnvironment { - return { ...environment, PYTHON: python }; + const result = { ...environment }; + if (git !== undefined) { + for (const name of Object.keys(result)) { + const normalized = name.toUpperCase(); + if (normalized === "PATH" || normalized.startsWith("GIT_")) { + delete result[name]; + } + } + result["PATH"] = git?.environment["PATH"] ?? ""; + result["CODEX_SECURITY_GIT"] = git?.executable ?? ""; + } + result["PYTHON"] = python; + return result; } export async function cleanupSdkDirectory(path: string): Promise { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 13e73152..9acf61ab 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2670,6 +2670,7 @@ describe("CodexSecurity orchestration", () => { const runtime = preparedRuntime(codexHome); return { ...runtime, + environment: { PATH: process.env["PATH"] }, plugin: { ...(runtime["plugin"] as Record), installedRoot: join( @@ -2723,6 +2724,7 @@ describe("CodexSecurity orchestration", () => { CODEX_SECURITY_SCAN_DIR: scanDir, CODEX_SECURITY_PLUGIN_ROOT: PLUGIN_ROOT, CODEX_SECURITY_TARGET_DISPLAY_NAME: basename(repository), + CODEX_SECURITY_GIT: expect.stringMatching(/git(?:\.exe)?$/iu), }); expect(environment).not.toHaveProperty("CODEX_SECURITY_TARGET_PATHS_JSON"); const targetPathsFile = environment?.["CODEX_SECURITY_TARGET_PATHS_FILE"]; diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index aa334915..db8c5adc 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1510,6 +1510,113 @@ describe("runtime directories and plugin Python boundary", () => { expect(result).toEqual({ ok: true }); }); + testPosix( + "uses trusted Git for workbench commands instead of a repository-local shim", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "node_modules", ".bin"); + const pluginRoot = join(root, "plugin"); + const marker = join(root, "repository-git-executed"); + await mkdir(shimDirectory, { recursive: true }); + await mkdir(join(pluginRoot, "scripts"), { recursive: true }); + await writeFile( + join(shimDirectory, "git"), + `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, + { mode: 0o700 }, + ); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, subprocess", + "assert os.environ.get('GIT_CONFIG_COUNT') is None", + "git = os.environ['CODEX_SECURITY_GIT']", + "completed = subprocess.run([git, '--version'], check=True, capture_output=True, text=True)", + "print(json.dumps({'git': git, 'path': os.environ.get('PATH'), 'version': completed.stdout.strip()}))", + ].join("\n"), + ); + const python = Bun.which("python3") ?? Bun.which("python"); + const git = Bun.which("git"); + expect(python).not.toBeNull(); + expect(git).not.toBeNull(); + + const result = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + PATH: `${shimDirectory}${delimiter}${dirname(git!)}`, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.fsmonitor", + GIT_CONFIG_VALUE_0: join(repository, "fsmonitor"), + }, + }, + ["test-command"], + ); + + expect(result).toMatchObject({ + git, + version: expect.stringMatching(/^git version /u), + }); + expect(String(result["path"]).split(delimiter)).not.toContain( + shimDirectory, + ); + expect(existsSync(marker)).toBe(false); + }, + ); + + testPosix( + "does not let diff ranking fall back to a repository-local Git shim", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "node_modules", ".bin"); + const marker = join(root, "rank-git-executed"); + const output = join(root, "rank-input.jsonl"); + await mkdir(shimDirectory, { recursive: true }); + await writeFile( + join(shimDirectory, "git"), + `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, + { mode: 0o700 }, + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const result = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + "HEAD", + "--mode", + "local-patch", + "--out", + output, + ], + { + encoding: "utf8", + env: { + PATH: shimDirectory, + CODEX_SECURITY_GIT: join(shimDirectory, "git"), + }, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "CODEX_SECURITY_GIT must stay outside the protected repository.", + ); + expect(existsSync(marker)).toBe(false); + expect(existsSync(output)).toBe(false); + }, + ); + test("preserves recorded artifact paths when archiving a completed scan", async () => { const root = await temporaryDirectory(); const scanDir = join(root, "scan"); @@ -1863,6 +1970,33 @@ describe("runtime directories and plugin Python boundary", () => { TEST: "1", PYTHON: managed, }); + expect( + pluginExecutionEnvironment( + managed, + { PATH: "/repository/bin", GIT_CONFIG_COUNT: "1", TEST: "1" }, + { + executable: "/trusted/bin/git", + environment: { PATH: "/trusted/bin" }, + }, + ), + ).toEqual({ + PATH: "/trusted/bin", + TEST: "1", + CODEX_SECURITY_GIT: "/trusted/bin/git", + PYTHON: managed, + }); + expect( + pluginExecutionEnvironment( + managed, + { Path: "/repository/bin", TEST: "1" }, + null, + ), + ).toEqual({ + PATH: "", + TEST: "1", + CODEX_SECURITY_GIT: "", + PYTHON: managed, + }); await expect( resolvePluginPython({ configuredPath: "/bin/true", diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index d095824f..3bd6dd0c 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -306,7 +306,13 @@ describe("malformed scan artifact recovery", () => { fixture.repository, join(fixture.stateDir, "checkout"), ], - { encoding: "utf8" }, + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git")!, + }, + }, ); expect(copied.status, copied.stderr).toBe(0); }