From 5dc0f1c2985e1e01e0c9cd46ae4a8053eef3096a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 15 Aug 2026 12:53:44 -0700 Subject: [PATCH 01/11] fix(git): pin trusted executables across scan hosts --- .../_bundled_plugin/.codex-plugin/plugin.json | 2 +- sdk/typescript/_bundled_plugin/.mcp.json | 1 + .../scripts/generate_in_scope_files.py | 24 +- .../scripts/generate_rank_input.py | 34 +- .../scripts/workbench_target.py | 110 ++++- sdk/typescript/src/api.ts | 41 +- sdk/typescript/src/targets.ts | 2 +- sdk/typescript/src/trusted-executable.ts | 29 +- sdk/typescript/src/version.ts | 2 +- .../tests-ts/workbench-trusted-git.test.ts | 457 ++++++++++++++++++ 10 files changed, 639 insertions(+), 63 deletions(-) create mode 100644 sdk/typescript/tests-ts/workbench-trusted-git.test.ts diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 04c30b9c..76688fe9 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.20", + "version": "0.1.22", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/.mcp.json b/sdk/typescript/_bundled_plugin/.mcp.json index 9a4fc836..40fbec04 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -28,6 +28,7 @@ "AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "PYTHON", + "CODEX_SECURITY_GIT", "CODEX_SECURITY_KNOWLEDGE_BASE", "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH", "CODEX_SECURITY_SCAN_ROOT", diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py index 6449bf74..65f26881 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -107,20 +107,18 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: def committed_changed_paths(repository: Path, base: str, head: str) -> list[tuple[Path, str]]: - result = subprocess.run( - [ - "git", - "-C", - str(repository), - "diff", - "--raw", - "-z", - "--diff-filter=ACMRD", - f"{base}..{head}", - ], - capture_output=True, - check=True, + from workbench_target import git_command + + result = git_command( + repository, + "diff", + "--raw", + "-z", + "--diff-filter=ACMRD", + f"{base}..{head}", + text=False, ) + result.check_returncode() fields = result.stdout.split(b"\0") changed: list[tuple[Path, str]] = [] index = 0 diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 92018d74..ef5722d6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -43,7 +43,7 @@ preview_for, preview_for_bytes, ) -from workbench_target import git_blob_bytes, git_directory_snapshot_paths +from workbench_target import git_blob_bytes, git_command, git_directory_snapshot_paths EXCLUDED_DIRS = { ".cache", @@ -608,21 +608,16 @@ def bind_repo_scopes(args: argparse.Namespace) -> None: def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]: - result = subprocess.run( - [ - "git", - "-C", - str(repo), - "diff", - "--name-status", - "-z", - "--diff-filter=ACMRD", - *diff_args, - ], - check=True, - capture_output=True, + result = git_command( + repo, + "diff", + "--name-status", + "-z", + "--diff-filter=ACMRD", + *diff_args, text=True, ) + result.check_returncode() fields = result.stdout.split("\0") if fields and not fields[-1]: fields.pop() @@ -646,12 +641,15 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple if mode == "local-patch": unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) - untracked = subprocess.run( - ["git", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"], - capture_output=True, + untracked = git_command( + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", text=True, - check=True, ) + untracked.check_returncode() combined = dict(staged) combined.update(unstaged) combined.update( diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 05faf6e0..4115a714 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -120,6 +120,83 @@ def _read_sized_nul_field( return output[offset:end], end + 1 +def _protected_git_root(target: Path) -> Path: + root = target.resolve() + for ancestor in (root, *root.parents): + try: + (ancestor / ".git").lstat() + except FileNotFoundError: + continue + root = ancestor + return root + + +def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None: + root = _protected_git_root(target) + configured = environment.get("CODEX_SECURITY_GIT") + if configured is not None: + if not configured: + return None + candidate = Path(configured) + windows = sys.platform == "win32" + if not candidate.is_absolute(): + raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + try: + parent = candidate.parent.resolve(strict=True) + canonical = candidate.resolve(strict=True) + except OSError as error: + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from error + if parent.is_relative_to(root) or canonical.is_relative_to(root): + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") + if ( + not canonical.is_file() + or ( + windows + and ( + candidate.suffix.lower() not in {".exe", ".com"} + or canonical.suffix.lower() not in {".exe", ".com"} + ) + ) + or not os.access(canonical, os.F_OK if windows else os.X_OK) + ): + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") + return str(canonical) + + entries: list[str] = [] + executable: str | None = None + names = ("git.exe", "git.com") if sys.platform == "win32" else ("git",) + for entry in os.get_exec_path(environment): + if not entry: + continue + try: + directory = Path(entry).resolve(strict=True) + except OSError: + continue + if directory.is_relative_to(root): + continue + candidate: str | None = None + safe = True + for name in names: + path = directory / name + try: + canonical = path.resolve(strict=True) + except OSError: + continue + if canonical.is_relative_to(root): + safe = False + break + if canonical.is_file() and os.access( + canonical, os.F_OK if sys.platform == "win32" else os.X_OK + ): + candidate = candidate or str(path) + if not safe: + continue + executable = executable or candidate + entries.append(str(directory)) + environment["PATH"] = os.pathsep.join(entries) + return executable + + def git_command( target: Path, *args: str, @@ -134,25 +211,28 @@ def git_command( for name in GIT_REPOSITORY_ENVIRONMENT: environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" + executable = _trusted_git_executable(target, environment) # Repository-local config is untrusted; fsmonitor may name an executable hook. - command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)] + command = [executable or "git", "-c", "core.fsmonitor=false", "-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] - try: - return subprocess.run( - full_command, - check=False, - capture_output=True, - env=environment, - text=text, - input=input_data, - ) - except FileNotFoundError: - # Git is optional for Codebase scans. Treat an unavailable executable like - # any other failed Git probe so the target falls back to a directory snapshot. - empty_output = "" if text else b"" - return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) + if executable is not None: + try: + return subprocess.run( + full_command, + check=False, + capture_output=True, + env=environment, + text=text, + input=input_data, + ) + except FileNotFoundError: + pass + # Git is optional for Codebase scans. Treat an unavailable executable like + # any other failed Git probe so the target falls back to a directory snapshot. + empty_output = "" if text else b"" + return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index fd60107e..3334dc63 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -109,6 +109,7 @@ import { enclosingGitWorktreeRoot, normalizeRepository, normalizeTarget, + outermostGitMarkerRoot, repositoryRevision, resolveRepositoryPath, type NormalizedTarget, @@ -118,6 +119,10 @@ import { validateCommittedDiffCheckout, validateMode, } from "./targets.js"; +import { + resolveTrustedExecutable, + trustedExecutableEnvironment, +} from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -635,6 +640,28 @@ export class CodexSecurity { protectedRoot, signal, }); + const pluginEnvironment = selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ); + const protectedGitRoot = await outermostGitMarkerRoot( + protectedRoot, + signal, + ); + const git = await resolveTrustedExecutable( + "git", + pluginEnvironment, + protectedGitRoot, + ); + const trustedPluginEnvironment = { + ...(await trustedExecutableEnvironment( + "rg", + git?.environment ?? pluginEnvironment, + protectedGitRoot, + )), + CODEX_SECURITY_GIT: git?.executable ?? "", + }; checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -812,11 +839,7 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment( - runtime.environment, - options.auth, - modelProvider, - ), + ...trustedPluginEnvironment, CODEX_SECURITY_STATE_DIR: stateDirectory, }, signal, @@ -1010,13 +1033,7 @@ export class CodexSecurity { const environment = { ...pluginExecutionEnvironment( python, - withoutCodexHome( - selectedScanEnvironment( - runtime.environment, - options.auth, - modelProvider, - ), - ), + withoutCodexHome(trustedPluginEnvironment), ), ...(externalProvider === null ? {} diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index f13858af..025052e1 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -418,7 +418,7 @@ async function gitOutput( return stdout.trim(); } -async function outermostGitMarkerRoot( +export async function outermostGitMarkerRoot( repository: string, signal?: AbortSignal, ): Promise { diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 5a0729cf..3567b5d6 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -12,6 +12,33 @@ export async function resolveTrustedExecutable( environment: Readonly>, protectedRoot: string, ): Promise { + const inspected = await inspectTrustedExecutable( + candidate, + environment, + protectedRoot, + ); + return inspected.executable === null + ? null + : { executable: inspected.executable, environment: inspected.environment }; +} + +export async function trustedExecutableEnvironment( + candidate: string, + environment: Readonly>, + protectedRoot: string, +): Promise> { + return (await inspectTrustedExecutable(candidate, environment, protectedRoot)) + .environment; +} + +async function inspectTrustedExecutable( + candidate: string, + environment: Readonly>, + protectedRoot: string, +): Promise<{ + executable: string | null; + environment: Record; +}> { const root = await realpath(protectedRoot).catch(() => resolve(protectedRoot), ); @@ -70,8 +97,6 @@ export async function resolveTrustedExecutable( continue; } } - if (executable === null) return null; - const sanitizedEnvironment = { ...environment }; for (const name of Object.keys(sanitizedEnvironment)) { if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 01dd5006..1bf429fb 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -8,7 +8,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.20" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.22" as const; const PACKAGE_NAME = "@openai/codex-security"; const VERSION_PATTERN = diff --git a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts new file mode 100644 index 00000000..d8f5ea43 --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -0,0 +1,457 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import type { CodexOptions } from "@openai/codex-sdk"; +import { afterEach, describe, expect, test } from "bun:test"; +import { CodexSecurity } from "../src/api.js"; +import { runWorkbench } from "../src/runtime.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; +import { preparedRuntime } from "./support/api-events.js"; + +const roots: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; +const statusProbe = ["git_command(Path(sys.argv[2]), 'status', text=True)"]; +const unsafeExecutable = "must stay outside the protected repository"; +const TestClient = CodexSecurity as unknown as new ( + config: Record, + dependencies: Record, +) => CodexSecurity; + +afterEach(() => { + for (const root of roots.splice(0)) { + const exfiltrated = existsSync(join(root, "exfiltrated-credential")); + rmSync(root, { recursive: true, force: true }); + expect(exfiltrated).toBe(false); + } +}); + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "trusted-git-"))); + roots.push(root); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "node_modules", ".bin"); + mkdirSync(shimDirectory, { recursive: true }); + const shim = join( + shimDirectory, + process.platform === "win32" ? "git.exe" : "git", + ); + writeFileSync( + shim, + '#!/bin/sh\nprintf "%s" "$GITHUB_TOKEN" > "$CODEX_SECURITY_TEST_MARKER"\nexit 1\n', + { mode: 0o700 }, + ); + const git = Bun.which("git"); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(git).not.toBeNull(); + expect(python).not.toBeNull(); + return { + root, + repository, + shim, + git: git!, + python: python!, + environment: { + HOME: root, + USERPROFILE: root, + ...(process.env["SystemRoot"] === undefined + ? {} + : { SystemRoot: process.env["SystemRoot"] }), + PATH: `${shimDirectory}${delimiter}${dirname(git!)}`, + GITHUB_TOKEN: "synthetic-github-credential", + CODEX_SECURITY_TEST_MARKER: join(root, "exfiltrated-credential"), + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "codexsecurity.synthetic", + GIT_CONFIG_VALUE_0: "operator-config-preserved", + PYTHONDONTWRITEBYTECODE: "1", + }, + }; +} + +function git( + target: ReturnType, + directory: string, + ...args: string[] +) { + return execFileSync( + target.git, + ["-C", directory, "-c", "user.name=x", "-c", "user.email=x@y", ...args], + { encoding: "utf8" }, + ).trim(); +} + +function probe( + target: ReturnType, + source: readonly string[], + options: { + repository?: string; + environment?: NodeJS.ProcessEnv; + } = {}, +) { + return spawnSync( + target.python, + [ + "-I", + "-B", + "-c", + [ + "import json, sys; from pathlib import Path", + "sys.path.insert(0, sys.argv[1]); from workbench_target import git_command", + ...source, + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + options.repository ?? target.repository, + ], + { + encoding: "utf8", + env: { ...target.environment, ...options.environment }, + cwd: target.repository, + }, + ); +} + +describe("bundled workbench trusted Git", () => { + testPosix("avoids repository shims and preserves user Git settings", () => { + const target = fixture(); + git(target, target.repository, "init", "-q"); + const nested = join(target.repository, "src", "nested"); + mkdirSync(nested, { recursive: true }); + + for (const [repository, environment] of [ + [target.repository, { CODEX_SECURITY_GIT: target.git }], + [nested, {}], + ] as const) { + const result = probe( + target, + [ + "result = git_command(Path(sys.argv[2]), 'config', '--get', 'codexsecurity.synthetic', text=True)", + "print(json.dumps({'git': result.args[0], 'value': result.stdout.strip(), 'status': result.returncode}))", + ], + { repository, environment }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + value: "operator-config-preserved", + status: 0, + }); + expect(realpathSync(JSON.parse(result.stdout).git)).toBe( + realpathSync(target.git), + ); + } + }); + + test("keeps Git optional when no trusted executable exists", () => { + const target = fixture(); + const result = probe( + target, + [ + "result = git_command(Path(sys.argv[2]), 'status', text=True)", + "print(json.dumps({'status': result.returncode, 'output': result.stdout}))", + ], + { environment: { CODEX_SECURITY_GIT: "" } }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ status: 127, output: "" }); + }); + + test("rejects explicitly selected repository-controlled Git", () => { + const target = fixture(); + const result = probe(target, statusProbe, { + environment: { CODEX_SECURITY_GIT: target.shim }, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain(unsafeExecutable); + }); + + testPosix("rejects repository symlinks and aliased parents", () => { + const target = fixture(); + const linkedGit = join(target.repository, "safe-looking-git"); + const repositoryAlias = join(target.root, "repository-alias"); + symlinkSync(target.git, linkedGit); + symlinkSync(target.repository, repositoryAlias, "junction"); + + const aliases = [linkedGit, join(repositoryAlias, "safe-looking-git")]; + for (const executable of aliases) { + const result = probe(target, statusProbe, { + environment: { CODEX_SECURITY_GIT: executable }, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain(unsafeExecutable); + } + }); + + test("ignores Windows batch shims, PATHEXT order, and the working directory", () => { + const target = fixture(); + const trustedDirectory = join(target.root, "trusted-bin"); + mkdirSync(trustedDirectory); + const executable = join(trustedDirectory, "git.exe"); + writeFileSync(executable, "synthetic native executable\n"); + for (const directory of [trustedDirectory, target.repository]) { + writeFileSync(join(directory, "git.cmd"), "synthetic batch shim\n"); + } + const result = probe( + target, + [ + "import os, workbench_target", + "workbench_target.sys.platform = 'win32'", + "selected = workbench_target._trusted_git_executable(Path(sys.argv[2]), dict(os.environ))", + "batch = Path(sys.argv[2]).parent / 'trusted-bin' / 'git.cmd'", + "try: workbench_target._trusted_git_executable(Path(sys.argv[2]), {**os.environ, 'CODEX_SECURITY_GIT': str(batch)})", + "except SystemExit: batch_rejected = True", + "else: batch_rejected = False", + "print(json.dumps({'executable': selected, 'batchRejected': batch_rejected}))", + ], + { + environment: { PATH: trustedDirectory, PATHEXT: ".CMD;.BAT;.EXE;.COM" }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + executable, + batchRejected: true, + }); + }); + + testPosix( + "uses trusted Git for real diff ranking and committed inventory", + () => { + const target = fixture(); + git(target, target.repository, "init", "-q"); + writeFileSync(join(target.repository, "source.py"), "before = True\n"); + git(target, target.repository, "add", "source.py"); + git(target, target.repository, "commit", "-qm", "base"); + const revision = git(target, target.repository, "rev-parse", "HEAD"); + writeFileSync(join(target.repository, "source.py"), "after = True\n"); + const output = join(target.root, "rank-input.jsonl"); + const revisionArgs = ["--base", revision, "--head", revision]; + const environment = { + ...target.environment, + CODEX_SECURITY_GIT: target.git, + }; + const result = spawnSync( + target.python, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + target.repository, + ...revisionArgs, + "--mode", + "local-patch", + "--out", + output, + ], + { encoding: "utf8", env: environment }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8"))).toMatchObject({ + path: "source.py", + }); + + git(target, target.repository, "add", "source.py"); + git(target, target.repository, "commit", "-qm", "head"); + const inventoryPath = join(target.root, "in-scope-files.txt"); + const inventory = spawnSync( + target.python, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + target.repository, + "--scope", + ".", + "--diff-base", + revision, + "--diff-head", + "HEAD", + "--out", + inventoryPath, + ], + { encoding: "utf8", env: environment }, + ); + expect(inventory.status, inventory.stderr).toBe(0); + expect(readFileSync(inventoryPath, "utf8")).toBe("source.py\n"); + }, + ); + + testPosix( + "keeps optional-Git scans and real inventory safe from repository ripgrep", + async () => { + const target = fixture(); + writeFileSync(join(target.repository, "source.py"), "value = 1\n"); + const unsafeDirectory = dirname(target.shim); + const repositoryRipgrep = join(unsafeDirectory, "rg"); + writeFileSync(repositoryRipgrep, readFileSync(target.shim), { + mode: 0o700, + }); + const aliasDirectory = join(target.root, "alias-bin"); + const safeDirectory = join(target.root, "trusted-bin"); + const codexHome = join(target.root, "codex-home"); + for (const directory of [aliasDirectory, safeDirectory, codexHome]) { + mkdirSync(directory); + } + symlinkSync(repositoryRipgrep, join(aliasDirectory, "rg")); + writeFileSync( + join(safeDirectory, "rg"), + '#!/bin/sh\nprintf "source.py\\n"\n', + { mode: 0o700 }, + ); + const environment = { + ...target.environment, + CODEX_SECURITY_STATE_DIR: join(target.root, "state"), + PATH: [unsafeDirectory, aliasDirectory, safeDirectory].join(delimiter), + }; + const observed: Array> = []; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + }), + resolvePluginPython: async () => target.python, + repositoryRevision: async () => null, + runWorkbench: async (...args: Parameters) => { + observed.push(args[0].environment); + return await runWorkbench(...args); + }, + createCodex: (options: CodexOptions) => { + observed.push(options.env ?? {}); + throw new Error("captured optional-Git environment"); + }, + }, + ); + try { + await expect( + client.run(target.repository, { + outputDir: join(target.root, "scan"), + }), + ).rejects.toThrow("captured optional-Git environment"); + } finally { + await client.close(); + } + + expect(observed.length).toBeGreaterThan(1); + for (const candidate of observed) { + expect(candidate["CODEX_SECURITY_GIT"]).toBe(""); + expect(candidate["PATH"]?.split(delimiter)).toEqual([safeDirectory]); + } + const output = join(target.root, "inventory.txt"); + const inventory = spawnSync( + target.python, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + target.repository, + "--scope", + ".", + "--out", + output, + ], + { encoding: "utf8", env: observed[0] }, + ); + expect(inventory.status, inventory.stderr).toBe(0); + expect(readFileSync(output, "utf8")).toBe("source.py\n"); + }, + ); + + testPosix( + "propagates outermost-root trusted Git through SDK, workbench, and MCP", + async () => { + const target = fixture(); + git(target, target.repository, "init", "-q"); + const nested = join(target.repository, "submodule"); + mkdirSync(nested); + git(target, nested, "init", "-q"); + writeFileSync(join(nested, "source.py"), "value = 1\n"); + git(target, nested, "add", "source.py"); + git(target, nested, "commit", "-qm", "base"); + const revision = git(target, nested, "rev-parse", "HEAD"); + const codexHome = join(target.root, "codex-home"); + mkdirSync(codexHome); + const aliasDirectory = join(target.root, "alias-bin"); + mkdirSync(aliasDirectory); + symlinkSync(target.shim, join(aliasDirectory, "rg")); + const environment = { + ...target.environment, + CODEX_SECURITY_STATE_DIR: join(target.root, "state"), + PATH: [dirname(target.shim), aliasDirectory, dirname(target.git)].join( + delimiter, + ), + }; + const environments: Array> = []; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + }), + resolvePluginPython: async () => target.python, + repositoryRevision: async () => revision, + runWorkbench: async (...args: Parameters) => { + environments.push(args[0].environment); + return await runWorkbench(...args); + }, + createCodex: (options: CodexOptions) => { + environments.push(options.env ?? {}); + throw new Error("captured scan environment"); + }, + }, + ); + + try { + await expect( + client.run(nested, { outputDir: join(target.root, "scan") }), + ).rejects.toThrow("captured scan environment"); + } finally { + await client.close(); + } + + expect(environments.length).toBeGreaterThan(1); + for (const observed of environments) { + expect(observed).toMatchObject({ + CODEX_SECURITY_GIT: target.git, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "codexsecurity.synthetic", + GIT_CONFIG_VALUE_0: "operator-config-preserved", + }); + expect(observed?.["PATH"]?.split(delimiter)).not.toContain( + dirname(target.shim), + ); + expect(observed?.["PATH"]?.split(delimiter)).not.toContain( + aliasDirectory, + ); + } + + const configuration = JSON.parse( + readFileSync(join(PLUGIN_ROOT, ".mcp.json"), "utf8"), + ) as { + mcpServers: Record; + }; + const allowed = configuration.mcpServers["codex-security"]!.env_vars; + const mcpEnvironment = Object.fromEntries( + Object.entries(environments.at(-1) ?? {}).filter(([name]) => + allowed.includes(name), + ), + ); + expect(mcpEnvironment).toMatchObject({ CODEX_SECURITY_GIT: target.git }); + }, + ); +}); From 9be47a60bbf82e634cd81a816f72290b4929d160 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 15 Aug 2026 13:10:58 -0700 Subject: [PATCH 02/11] fix(git): preserve sanitized and inherited executable paths --- sdk/typescript/src/api.ts | 9 +++- sdk/typescript/src/trusted-executable.ts | 12 +++-- .../tests-ts/workbench-trusted-git.test.ts | 48 +++++++++++++++++-- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 3334dc63..7444bbf1 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -649,15 +649,20 @@ export class CodexSecurity { protectedRoot, signal, ); - const git = await resolveTrustedExecutable( + const gitEnvironment = await trustedExecutableEnvironment( "git", pluginEnvironment, protectedGitRoot, ); + const git = await resolveTrustedExecutable( + "git", + gitEnvironment, + protectedGitRoot, + ); const trustedPluginEnvironment = { ...(await trustedExecutableEnvironment( "rg", - git?.environment ?? pluginEnvironment, + git?.environment ?? gitEnvironment, protectedGitRoot, )), CODEX_SECURITY_GIT: git?.executable ?? "", diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 3567b5d6..1b0ebe5c 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -98,12 +98,14 @@ async function inspectTrustedExecutable( } } const sanitizedEnvironment = { ...environment }; - for (const name of Object.keys(sanitizedEnvironment)) { - if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; + if (path !== undefined) { + for (const name of Object.keys(sanitizedEnvironment)) { + if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; + } + sanitizedEnvironment["PATH"] = entries + .filter((entry) => !unsafeEntries.has(entry)) + .join(delimiter); } - sanitizedEnvironment["PATH"] = entries - .filter((entry) => !unsafeEntries.has(entry)) - .join(delimiter); return { executable, environment: sanitizedEnvironment }; } diff --git a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts index d8f5ea43..5dd27402 100644 --- a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -15,6 +15,7 @@ import type { CodexOptions } from "@openai/codex-sdk"; import { afterEach, describe, expect, test } from "bun:test"; import { CodexSecurity } from "../src/api.js"; import { runWorkbench } from "../src/runtime.js"; +import { trustedExecutableEnvironment } from "../src/trusted-executable.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { preparedRuntime } from "./support/api-events.js"; @@ -163,6 +164,30 @@ describe("bundled workbench trusted Git", () => { expect(JSON.parse(result.stdout)).toEqual({ status: 127, output: "" }); }); + test("preserves absent PATH while clearing explicitly unsafe PATH", async () => { + const target = fixture(); + const missing = await trustedExecutableEnvironment( + "git", + { HOME: target.root }, + target.repository, + ); + expect(missing).not.toHaveProperty("PATH"); + + const undefinedPath = await trustedExecutableEnvironment( + "git", + { HOME: target.root, PATH: undefined }, + target.repository, + ); + expect(undefinedPath["PATH"]).toBeUndefined(); + + const unsafe = await trustedExecutableEnvironment( + "git", + { HOME: target.root, PATH: dirname(target.shim) }, + target.repository, + ); + expect(unsafe["PATH"]).toBe(""); + }); + test("rejects explicitly selected repository-controlled Git", () => { const target = fixture(); const result = probe(target, statusProbe, { @@ -287,7 +312,7 @@ describe("bundled workbench trusted Git", () => { ); testPosix( - "keeps optional-Git scans and real inventory safe from repository ripgrep", + "keeps optional-Git scans safe from repository Git and ripgrep aliases", async () => { const target = fixture(); writeFileSync(join(target.repository, "source.py"), "value = 1\n"); @@ -296,12 +321,19 @@ describe("bundled workbench trusted Git", () => { writeFileSync(repositoryRipgrep, readFileSync(target.shim), { mode: 0o700, }); + const gitAliasDirectory = join(target.root, "git-alias-bin"); const aliasDirectory = join(target.root, "alias-bin"); const safeDirectory = join(target.root, "trusted-bin"); const codexHome = join(target.root, "codex-home"); - for (const directory of [aliasDirectory, safeDirectory, codexHome]) { + for (const directory of [ + gitAliasDirectory, + aliasDirectory, + safeDirectory, + codexHome, + ]) { mkdirSync(directory); } + symlinkSync(target.shim, join(gitAliasDirectory, "git")); symlinkSync(repositoryRipgrep, join(aliasDirectory, "rg")); writeFileSync( join(safeDirectory, "rg"), @@ -311,7 +343,12 @@ describe("bundled workbench trusted Git", () => { const environment = { ...target.environment, CODEX_SECURITY_STATE_DIR: join(target.root, "state"), - PATH: [unsafeDirectory, aliasDirectory, safeDirectory].join(delimiter), + PATH: [ + unsafeDirectory, + gitAliasDirectory, + aliasDirectory, + safeDirectory, + ].join(delimiter), }; const observed: Array> = []; const client = new TestClient( @@ -345,6 +382,11 @@ describe("bundled workbench trusted Git", () => { } expect(observed.length).toBeGreaterThan(1); + const unexpectedGit = spawnSync("git", ["--version"], { + encoding: "utf8", + env: observed[0], + }); + expect(unexpectedGit.error).toMatchObject({ code: "ENOENT" }); for (const candidate of observed) { expect(candidate["CODEX_SECURITY_GIT"]).toBe(""); expect(candidate["PATH"]?.split(delimiter)).toEqual([safeDirectory]); From 49000ab013d2307b42a780cc49cb258a1581348b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 15 Aug 2026 13:33:01 -0700 Subject: [PATCH 03/11] fix(git): anchor trusted tools to the scanned repository --- .../scripts/workbench_target.py | 2 +- sdk/typescript/src/api.ts | 16 +- .../tests-ts/workbench-trusted-git.test.ts | 170 +++++++++++++++++- 3 files changed, 180 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 4115a714..e9d38f14 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -160,7 +160,7 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | or not os.access(canonical, os.F_OK if windows else os.X_OK) ): raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") - return str(canonical) + return configured entries: list[str] = [] executable: str | None = None diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 7444bbf1..d34ec118 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -645,10 +645,7 @@ export class CodexSecurity { options.auth, modelProvider, ); - const protectedGitRoot = await outermostGitMarkerRoot( - protectedRoot, - signal, - ); + const protectedGitRoot = await outermostGitMarkerRoot(repo, signal); const gitEnvironment = await trustedExecutableEnvironment( "git", pluginEnvironment, @@ -1794,8 +1791,17 @@ export class CodexSecurity { validateMode(normalized, mode); await validateCommittedDiffCheckout(repo, normalized, signal); throwIfAborted(signal); + const enclosingRoot = await enclosingGitWorktreeRoot(repo, signal); + const repositoryRelative = + enclosingRoot === null ? null : relative(enclosingRoot, repo); const protectedRoot = - (await enclosingGitWorktreeRoot(repo, signal)) ?? repo; + enclosingRoot !== null && + repositoryRelative !== null && + repositoryRelative !== ".." && + !repositoryRelative.startsWith(`..${sep}`) && + !isAbsolute(repositoryRelative) + ? enclosingRoot + : repo; const requestedOutput = await validateOutputDir( options.outputDir, options.archiveExisting, diff --git a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts index 5dd27402..f3fc1132 100644 --- a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -14,7 +14,7 @@ import { delimiter, dirname, join } from "node:path"; import type { CodexOptions } from "@openai/codex-sdk"; import { afterEach, describe, expect, test } from "bun:test"; import { CodexSecurity } from "../src/api.js"; -import { runWorkbench } from "../src/runtime.js"; +import { resolvePluginPython, runWorkbench } from "../src/runtime.js"; import { trustedExecutableEnvironment } from "../src/trusted-executable.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { preparedRuntime } from "./support/api-events.js"; @@ -197,14 +197,49 @@ describe("bundled workbench trusted Git", () => { expect(result.stderr).toContain(unsafeExecutable); }); + testPosix("preserves trusted multicall Git symlink invocation", () => { + const target = fixture(); + const trustedDirectory = join(target.root, "trusted-bin"); + mkdirSync(trustedDirectory); + const multicall = join(trustedDirectory, "multicall"); + const invocation = join(trustedDirectory, "git"); + writeFileSync( + multicall, + '#!/bin/sh\ncase "$0" in */git) printf "%s" "$0";; *) exit 23;; esac\n', + { mode: 0o700 }, + ); + symlinkSync(multicall, invocation); + + const result = probe( + target, + [ + "result = git_command(Path(sys.argv[2]), 'status', text=True)", + "print(json.dumps({'git': result.args[0], 'invocation': result.stdout, 'status': result.returncode}))", + ], + { environment: { CODEX_SECURITY_GIT: invocation } }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + git: invocation, + invocation, + status: 0, + }); + }); + testPosix("rejects repository symlinks and aliased parents", () => { const target = fixture(); const linkedGit = join(target.repository, "safe-looking-git"); const repositoryAlias = join(target.root, "repository-alias"); + const externalAlias = join(target.root, "external-git"); symlinkSync(target.git, linkedGit); symlinkSync(target.repository, repositoryAlias, "junction"); + symlinkSync(target.shim, externalAlias); - const aliases = [linkedGit, join(repositoryAlias, "safe-looking-git")]; + const aliases = [ + linkedGit, + join(repositoryAlias, "safe-looking-git"), + externalAlias, + ]; for (const executable of aliases) { const result = probe(target, statusProbe, { environment: { CODEX_SECURITY_GIT: executable }, @@ -311,6 +346,137 @@ describe("bundled workbench trusted Git", () => { }, ); + testPosix( + "keeps core.worktree redirection from trusting scanned repository aliases", + async () => { + const target = fixture(); + git(target, target.repository, "init", "-q"); + const redirectedRoot = join(target.root, "redirected-worktree"); + mkdirSync(redirectedRoot); + git(target, target.repository, "config", "core.worktree", redirectedRoot); + expect( + git(target, target.repository, "rev-parse", "--show-toplevel"), + ).toBe(redirectedRoot); + writeFileSync(join(target.repository, "source.py"), "value = 1\n"); + + const unsafeDirectory = dirname(target.shim); + const repositoryRipgrep = join(unsafeDirectory, "rg"); + const repositoryPython = join(unsafeDirectory, "python"); + writeFileSync(repositoryRipgrep, readFileSync(target.shim), { + mode: 0o700, + }); + writeFileSync(repositoryPython, readFileSync(target.shim), { + mode: 0o700, + }); + const gitAlias = join(target.root, "git-alias-bin"); + const ripgrepAlias = join(target.root, "rg-alias-bin"); + const safeRipgrep = join(target.root, "trusted-rg-bin"); + const codexHome = join(target.root, "codex-home"); + for (const directory of [ + gitAlias, + ripgrepAlias, + safeRipgrep, + codexHome, + ]) { + mkdirSync(directory); + } + symlinkSync(target.shim, join(gitAlias, "git")); + symlinkSync(repositoryRipgrep, join(ripgrepAlias, "rg")); + writeFileSync( + join(safeRipgrep, "rg"), + '#!/bin/sh\nprintf "source.py\\n"\n', + { mode: 0o700 }, + ); + const environment = { + ...target.environment, + CODEX_SECURITY_STATE_DIR: join(target.root, "state"), + PATH: [ + unsafeDirectory, + gitAlias, + ripgrepAlias, + safeRipgrep, + dirname(target.git), + ].join(delimiter), + }; + const observed: Array> = []; + let pythonProtectedRoot: string | undefined; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + }), + resolvePluginPython: async (options: { protectedRoot?: string }) => { + pythonProtectedRoot = options.protectedRoot; + return target.python; + }, + repositoryRevision: async () => null, + runWorkbench: async (...args: Parameters) => { + observed.push(args[0].environment); + throw new Error("captured redirected-worktree environment"); + }, + }, + ); + try { + await expect( + client.preflight(target.repository, { + outputDir: join(target.repository, "scan-output"), + }), + ).rejects.toThrow("outside"); + await expect( + client.run(target.repository, { + outputDir: join(target.root, "scan"), + }), + ).rejects.toThrow("captured redirected-worktree environment"); + } finally { + await client.close(); + } + + expect(observed.length).toBe(1); + expect(pythonProtectedRoot).toBe(target.repository); + await expect( + resolvePluginPython({ + configuredPath: repositoryPython, + environment, + protectedRoot: pythonProtectedRoot, + }), + ).rejects.toThrow(); + for (const candidate of observed) { + expect(candidate["CODEX_SECURITY_GIT"]).toBe(target.git); + expect(candidate["PATH"]?.split(delimiter)).toEqual([ + safeRipgrep, + dirname(target.git), + ]); + } + const trustedGit = spawnSync("git", ["--version"], { + encoding: "utf8", + env: observed[0], + }); + expect(trustedGit.status, trustedGit.stderr).toBe(0); + + const inventoryPath = join(target.root, "inventory.txt"); + const inventory = spawnSync( + target.python, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + target.repository, + "--scope", + ".", + "--out", + inventoryPath, + ], + { encoding: "utf8", env: observed[0] }, + ); + expect(inventory.status, inventory.stderr).toBe(0); + expect(readFileSync(inventoryPath, "utf8")).toBe("source.py\n"); + }, + ); + testPosix( "keeps optional-Git scans safe from repository Git and ripgrep aliases", async () => { From 78123a7e995c25415cf7a3244393be90025e6981 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 15 Aug 2026 13:56:44 -0700 Subject: [PATCH 04/11] fix(git): reject repository aliases by filesystem identity --- .../scripts/workbench_target.py | 30 ++-- .../tests-ts/workbench-trusted-git.test.ts | 130 ++++++++++++++++++ 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index e9d38f14..311c73df 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -131,6 +131,13 @@ def _protected_git_root(target: Path) -> Path: return root +def _inside_protected_git_root(candidate: Path, root: Path) -> bool: + return candidate.is_relative_to(root) or ( + len(candidate.parts) >= len(root.parts) + and Path(*candidate.parts[: len(root.parts)]).samefile(root) + ) + + def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None: root = _protected_git_root(target) configured = environment.get("CODEX_SECURITY_GIT") @@ -141,20 +148,25 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | windows = sys.platform == "win32" if not candidate.is_absolute(): raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + invocation = Path(os.path.abspath(configured)) + if invocation.is_relative_to(root): + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") try: - parent = candidate.parent.resolve(strict=True) + for ancestor in (candidate, *candidate.parents, invocation, *invocation.parents): + if _inside_protected_git_root(ancestor.resolve(strict=True), root): + raise SystemExit( + "CODEX_SECURITY_GIT must stay outside the protected repository." + ) canonical = candidate.resolve(strict=True) except OSError as error: raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from error - if parent.is_relative_to(root) or canonical.is_relative_to(root): - raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") if ( not canonical.is_file() or ( windows and ( candidate.suffix.lower() not in {".exe", ".com"} - or canonical.suffix.lower() not in {".exe", ".com"} + or canonical.suffix.lower() in {".bat", ".cmd"} ) ) or not os.access(canonical, os.F_OK if windows else os.X_OK) @@ -170,21 +182,21 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | continue try: directory = Path(entry).resolve(strict=True) + if _inside_protected_git_root(directory, root): + continue except OSError: continue - if directory.is_relative_to(root): - continue candidate: str | None = None safe = True for name in names: path = directory / name try: canonical = path.resolve(strict=True) + if _inside_protected_git_root(canonical, root): + safe = False + break except OSError: continue - if canonical.is_relative_to(root): - safe = False - break if canonical.is_file() and os.access( canonical, os.F_OK if sys.platform == "win32" else os.X_OK ): diff --git a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts index f3fc1132..692798c0 100644 --- a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -148,6 +149,51 @@ describe("bundled workbench trusted Git", () => { realpathSync(target.git), ); } + + const uppercaseRepository = join(target.root, "REPOSITORY"); + if (existsSync(uppercaseRepository)) { + const repositoryIdentity = statSync(target.repository); + const uppercaseIdentity = statSync(uppercaseRepository); + if ( + repositoryIdentity.dev === uppercaseIdentity.dev && + repositoryIdentity.ino === uppercaseIdentity.ino + ) { + const unsafeDirectory = join( + uppercaseRepository, + "node_modules", + ".bin", + ); + const externalAlias = join(target.root, "casefold-alias-bin"); + mkdirSync(externalAlias); + symlinkSync(join(unsafeDirectory, "git"), join(externalAlias, "git")); + const result = probe( + target, + [ + "import os, workbench_target", + "environment = dict(os.environ)", + "selected = workbench_target._trusted_git_executable(Path(sys.argv[2]), environment)", + "result = git_command(Path(sys.argv[2]), 'config', '--get', 'codexsecurity.synthetic', text=True)", + "print(json.dumps({'configured': 'CODEX_SECURITY_GIT' in os.environ, 'git': result.args[0], 'selected': selected, 'path': environment['PATH'], 'status': result.returncode, 'value': result.stdout.strip()}))", + ], + { + environment: { + PATH: [unsafeDirectory, externalAlias, dirname(target.git)].join( + delimiter, + ), + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + configured: false, + git: target.git, + selected: target.git, + path: dirname(target.git), + status: 0, + value: "operator-config-preserved", + }); + } + } }); test("keeps Git optional when no trusted executable exists", () => { @@ -231,15 +277,60 @@ describe("bundled workbench trusted Git", () => { const linkedGit = join(target.repository, "safe-looking-git"); const repositoryAlias = join(target.root, "repository-alias"); const externalAlias = join(target.root, "external-git"); + const repositoryOwnedLink = join(target.repository, "trusted-link"); + const outside = join(target.root, "outside"); + mkdirSync(outside); symlinkSync(target.git, linkedGit); symlinkSync(target.repository, repositoryAlias, "junction"); symlinkSync(target.shim, externalAlias); + symlinkSync(dirname(target.git), repositoryOwnedLink, "junction"); + const rawRepositoryAlias = join(outside, "repo-alias"); + symlinkSync(target.repository, rawRepositoryAlias, "junction"); + for (const directory of [target.root, outside]) { + symlinkSync( + dirname(target.git), + join(directory, "escaped-bin"), + "junction", + ); + } const aliases = [ linkedGit, join(repositoryAlias, "safe-looking-git"), externalAlias, + join(repositoryOwnedLink, "git"), + join(repositoryAlias, "trusted-link", "git"), + `${outside}/../repository/trusted-link/git`, + `${rawRepositoryAlias}/../escaped-bin/git`, ]; + const mixedCaseAlias = join( + target.root, + "REPOSITORY", + "trusted-link", + "git", + ); + if (existsSync(mixedCaseAlias)) { + const repositoryIdentity = statSync(target.repository); + const aliasIdentity = statSync(join(target.root, "REPOSITORY")); + if ( + repositoryIdentity.dev === aliasIdentity.dev && + repositoryIdentity.ino === aliasIdentity.ino + ) { + aliases.push(mixedCaseAlias); + const mixedCaseExternal = join(outside, "case-git"); + symlinkSync( + join(target.root, "REPOSITORY", "node_modules", ".bin", "git"), + mixedCaseExternal, + ); + aliases.push(mixedCaseExternal); + const nested = join(target.repository, "nested"); + mkdirSync(nested); + symlinkSync(dirname(target.git), join(nested, "escape"), "junction"); + const nestedAlias = join(outside, "nested-alias"); + symlinkSync(join(target.root, "REPOSITORY", "nested"), nestedAlias); + aliases.push(join(nestedAlias, "escape", "git")); + } + } for (const executable of aliases) { const result = probe(target, statusProbe, { environment: { CODEX_SECURITY_GIT: executable }, @@ -281,6 +372,45 @@ describe("bundled workbench trusted Git", () => { }); }); + testPosix( + "accepts Windows Git aliases to extensionless executables, not batch files", + () => { + const target = fixture(); + const trustedDirectory = join(target.root, "trusted-bin"); + mkdirSync(trustedDirectory); + const multicall = join(trustedDirectory, "multicall"); + const executable = join(trustedDirectory, "git.exe"); + const batch = join(trustedDirectory, "git.cmd"); + const batchAlias = join(trustedDirectory, "batch.exe"); + writeFileSync(multicall, "synthetic native executable\n"); + writeFileSync(batch, "synthetic batch shim\n"); + symlinkSync(multicall, executable); + symlinkSync(batch, batchAlias); + + const result = probe( + target, + [ + "import os, workbench_target", + "workbench_target.sys.platform = 'win32'", + "root = Path(sys.argv[2]).parent / 'trusted-bin'", + "selected = workbench_target._trusted_git_executable(Path(sys.argv[2]), dict(os.environ))", + "configured = workbench_target._trusted_git_executable(Path(sys.argv[2]), {**os.environ, 'CODEX_SECURITY_GIT': str(root / 'git.exe')})", + "try: workbench_target._trusted_git_executable(Path(sys.argv[2]), {**os.environ, 'CODEX_SECURITY_GIT': str(root / 'batch.exe')})", + "except SystemExit: batch_rejected = True", + "else: batch_rejected = False", + "print(json.dumps({'executable': selected, 'configured': configured, 'batchRejected': batch_rejected}))", + ], + { environment: { PATH: trustedDirectory } }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + executable, + configured: executable, + batchRejected: true, + }); + }, + ); + testPosix( "uses trusted Git for real diff ranking and committed inventory", () => { From 15a97eff00830ba32beb2564434808ad6899b346 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:11:07 -0700 Subject: [PATCH 05/11] fix(git): reuse trusted lookup and preserve platform defaults --- .../scripts/workbench_target.py | 45 +++++++++---------- sdk/typescript/src/api.ts | 21 +++------ sdk/typescript/src/trusted-executable.ts | 30 +++++-------- .../tests-ts/workbench-trusted-git.test.ts | 36 ++++++++++----- 4 files changed, 65 insertions(+), 67 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 311c73df..29a07d37 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -138,6 +138,21 @@ def _inside_protected_git_root(candidate: Path, root: Path) -> bool: ) +def _is_git_executable(candidate: Path, canonical: Path) -> bool: + windows = sys.platform == "win32" + return ( + canonical.is_file() + and ( + not windows + or ( + candidate.suffix.lower() in {".exe", ".com"} + and canonical.suffix.lower() not in {".bat", ".cmd"} + ) + ) + and os.access(canonical, os.F_OK if windows else os.X_OK) + ) + + def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None: root = _protected_git_root(target) configured = environment.get("CODEX_SECURITY_GIT") @@ -145,32 +160,18 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | if not configured: return None candidate = Path(configured) - windows = sys.platform == "win32" if not candidate.is_absolute(): raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") - invocation = Path(os.path.abspath(configured)) - if invocation.is_relative_to(root): - raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") try: - for ancestor in (candidate, *candidate.parents, invocation, *invocation.parents): - if _inside_protected_git_root(ancestor.resolve(strict=True), root): - raise SystemExit( - "CODEX_SECURITY_GIT must stay outside the protected repository." - ) canonical = candidate.resolve(strict=True) + if _inside_protected_git_root(canonical, root) or any( + _inside_protected_git_root(ancestor.resolve(strict=True), root) + for ancestor in candidate.parents + ): + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") except OSError as error: raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from error - if ( - not canonical.is_file() - or ( - windows - and ( - candidate.suffix.lower() not in {".exe", ".com"} - or canonical.suffix.lower() in {".bat", ".cmd"} - ) - ) - or not os.access(canonical, os.F_OK if windows else os.X_OK) - ): + if not _is_git_executable(candidate, canonical): raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") return configured @@ -197,9 +198,7 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | break except OSError: continue - if canonical.is_file() and os.access( - canonical, os.F_OK if sys.platform == "win32" else os.X_OK - ): + if _is_git_executable(path, canonical): candidate = candidate or str(path) if not safe: continue diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e75c833b..5f7bb474 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -120,10 +120,7 @@ import { validateCommittedDiffCheckout, validateMode, } from "./targets.js"; -import { - resolveTrustedExecutable, - trustedExecutableEnvironment, -} from "./trusted-executable.js"; +import { inspectTrustedExecutable } from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -648,23 +645,19 @@ export class CodexSecurity { modelProvider, ); const protectedGitRoot = await outermostGitMarkerRoot(repo, signal); - const gitEnvironment = await trustedExecutableEnvironment( + const git = await inspectTrustedExecutable( "git", pluginEnvironment, protectedGitRoot, ); - const git = await resolveTrustedExecutable( - "git", - gitEnvironment, + const ripgrep = await inspectTrustedExecutable( + "rg", + git.environment, protectedGitRoot, ); const trustedPluginEnvironment = { - ...(await trustedExecutableEnvironment( - "rg", - git?.environment ?? gitEnvironment, - protectedGitRoot, - )), - CODEX_SECURITY_GIT: git?.executable ?? "", + ...ripgrep.environment, + CODEX_SECURITY_GIT: git.executable ?? "", }; checkOpen(); const scanOutputRoot = diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 1b0ebe5c..10d11559 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -22,16 +22,7 @@ export async function resolveTrustedExecutable( : { executable: inspected.executable, environment: inspected.environment }; } -export async function trustedExecutableEnvironment( - candidate: string, - environment: Readonly>, - protectedRoot: string, -): Promise> { - return (await inspectTrustedExecutable(candidate, environment, protectedRoot)) - .environment; -} - -async function inspectTrustedExecutable( +export async function inspectTrustedExecutable( candidate: string, environment: Readonly>, protectedRoot: string, @@ -45,8 +36,13 @@ async function inspectTrustedExecutable( const path = Object.entries(environment).find( ([name]) => name.toUpperCase() === "PATH", )?.[1]; + // Match child_process lookup defaults without broadening an explicit PATH. + const searchPath = + path ?? + (process.platform === "win32" ? process.env["PATH"] : "/usr/bin:/bin") ?? + ""; const entries: string[] = []; - for (const entry of path?.split(delimiter) ?? []) { + for (const entry of searchPath.split(delimiter)) { if (entry.length === 0) continue; const canonical = await realpath(entry).catch(() => null); if (canonical === null || isWithin(root, canonical)) continue; @@ -98,14 +94,12 @@ async function inspectTrustedExecutable( } } const sanitizedEnvironment = { ...environment }; - if (path !== undefined) { - for (const name of Object.keys(sanitizedEnvironment)) { - if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; - } - sanitizedEnvironment["PATH"] = entries - .filter((entry) => !unsafeEntries.has(entry)) - .join(delimiter); + for (const name of Object.keys(sanitizedEnvironment)) { + if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; } + sanitizedEnvironment["PATH"] = entries + .filter((entry) => !unsafeEntries.has(entry)) + .join(delimiter); return { executable, environment: sanitizedEnvironment }; } diff --git a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts index 692798c0..736df477 100644 --- a/sdk/typescript/tests-ts/workbench-trusted-git.test.ts +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -16,7 +16,7 @@ import type { CodexOptions } from "@openai/codex-sdk"; import { afterEach, describe, expect, test } from "bun:test"; import { CodexSecurity } from "../src/api.js"; import { resolvePluginPython, runWorkbench } from "../src/runtime.js"; -import { trustedExecutableEnvironment } from "../src/trusted-executable.js"; +import { inspectTrustedExecutable } from "../src/trusted-executable.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { preparedRuntime } from "./support/api-events.js"; @@ -210,28 +210,40 @@ describe("bundled workbench trusted Git", () => { expect(JSON.parse(result.stdout)).toEqual({ status: 127, output: "" }); }); - test("preserves absent PATH while clearing explicitly unsafe PATH", async () => { + test("uses default lookup only when PATH is absent", async () => { const target = fixture(); - const missing = await trustedExecutableEnvironment( + const defaultPath = + process.platform === "win32" ? process.env["PATH"] : "/usr/bin:/bin"; + const expected = await inspectTrustedExecutable( "git", - { HOME: target.root }, + { HOME: target.root, PATH: defaultPath ?? "" }, target.repository, ); - expect(missing).not.toHaveProperty("PATH"); - - const undefinedPath = await trustedExecutableEnvironment( + const missing = await inspectTrustedExecutable( "git", - { HOME: target.root, PATH: undefined }, + { HOME: target.root }, target.repository, ); - expect(undefinedPath["PATH"]).toBeUndefined(); + expect(missing).toEqual(expected); - const unsafe = await trustedExecutableEnvironment( + const undefinedPath = await inspectTrustedExecutable( "git", - { HOME: target.root, PATH: dirname(target.shim) }, + { HOME: target.root, PATH: undefined }, target.repository, ); - expect(unsafe["PATH"]).toBe(""); + expect(undefinedPath).toEqual(expected); + + for (const path of ["", dirname(target.shim)]) { + const unavailable = await inspectTrustedExecutable( + "git", + { HOME: target.root, PATH: path }, + target.repository, + ); + expect(unavailable).toEqual({ + executable: null, + environment: { HOME: target.root, PATH: "" }, + }); + } }); test("rejects explicitly selected repository-controlled Git", () => { From 357b437555ff6d0083f6ec5ac5c1c716f7dffe02 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:12:07 -0700 Subject: [PATCH 06/11] fix(git): bind optional tools with platform-aware environments --- sdk/typescript/_bundled_plugin/.mcp.json | 1 + .../scripts/generate_in_scope_files.py | 16 +- .../scripts/generate_rank_input.py | 11 +- .../scripts/workbench_target.py | 57 ++++- sdk/typescript/src/api.ts | 2 + sdk/typescript/src/trusted-executable.ts | 14 +- .../workbench-tool-environment.test.ts | 227 ++++++++++++++++++ 7 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 sdk/typescript/tests-ts/workbench-tool-environment.test.ts diff --git a/sdk/typescript/_bundled_plugin/.mcp.json b/sdk/typescript/_bundled_plugin/.mcp.json index 40fbec04..72f155a6 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -29,6 +29,7 @@ "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "PYTHON", "CODEX_SECURITY_GIT", + "CODEX_SECURITY_RG", "CODEX_SECURITY_KNOWLEDGE_BASE", "CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH", "CODEX_SECURITY_SCAN_ROOT", diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py index 65f26881..158b1ca6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -10,6 +10,10 @@ import tempfile from pathlib import Path +# Some plugin hosts launch Python with safe-path isolation enabled. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workbench_target import git_command, ripgrep_command + class InventoryError(ValueError): """Raised when the repository, scope, or inventory cannot be used safely.""" @@ -70,7 +74,6 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: """Atomically write the exact ripgrep inventory sorted as ``LC_ALL=C``.""" command = [ - "rg", "--files", "--hidden", "--no-ignore", @@ -83,13 +86,7 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: ] with tempfile.TemporaryFile(mode="w+b") as inventory: try: - result = subprocess.run( - command, - cwd=repository, - stdout=inventory, - stderr=subprocess.PIPE, - check=False, - ) + result = ripgrep_command(repository, *command, stdout=inventory) except OSError as error: raise InventoryError(f"could not run ripgrep: {error}") from error @@ -107,8 +104,6 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: def committed_changed_paths(repository: Path, base: str, head: str) -> list[tuple[Path, str]]: - from workbench_target import git_command - result = git_command( repository, "diff", @@ -144,7 +139,6 @@ def generate_diff_in_scope_files( output: Path, ) -> int: """Reuse the existing diff selection without generating previews or duplicate worklists.""" - sys.path.insert(0, str(Path(__file__).resolve().parent)) from generate_rank_input import git_changed_paths, path_is_excluded from rank_preview import ( DEFAULT_PREVIEW_BYTES, diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index ef5722d6..b7a27702 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -29,7 +29,6 @@ import json import os import re -import subprocess import sys from collections import Counter from collections.abc import Callable @@ -43,7 +42,12 @@ preview_for, preview_for_bytes, ) -from workbench_target import git_blob_bytes, git_command, git_directory_snapshot_paths +from workbench_target import ( + git_blob_bytes, + git_command, + git_directory_snapshot_paths, + ripgrep_command, +) EXCLUDED_DIRS = { ".cache", @@ -521,7 +525,6 @@ def make_repo_scope_input(args: argparse.Namespace) -> None: candidates = git_candidates else: command = [ - "rg", "--files", "--hidden", "--no-require-git", @@ -532,7 +535,7 @@ def make_repo_scope_input(args: argparse.Namespace) -> None: str(scope_path.relative_to(repo)), ] try: - result = subprocess.run(command, cwd=repo, capture_output=True, check=False) + result = ripgrep_command(repo, *command) except OSError as exc: ignore_names = (".gitignore", ".ignore", ".rgignore") ancestors = (scope_path, *scope_path.parents) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 29a07d37..f1acdc69 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -11,7 +11,7 @@ import subprocess import sys from pathlib import Path -from typing import Any +from typing import IO, Any # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -138,7 +138,7 @@ def _inside_protected_git_root(candidate: Path, root: Path) -> bool: ) -def _is_git_executable(candidate: Path, canonical: Path) -> bool: +def _is_native_executable(candidate: Path, canonical: Path) -> bool: windows = sys.platform == "win32" return ( canonical.is_file() @@ -153,31 +153,43 @@ def _is_git_executable(candidate: Path, canonical: Path) -> bool: ) -def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None: +def _trusted_executable( + target: Path, + environment: dict[str, str], + name: str, +) -> str | None: root = _protected_git_root(target) - configured = environment.get("CODEX_SECURITY_GIT") + setting = f"CODEX_SECURITY_{name.upper()}" + configured = environment.get(setting) if configured is not None: if not configured: return None candidate = Path(configured) if not candidate.is_absolute(): - raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + raise SystemExit(f"{setting} must name an absolute trusted executable.") try: canonical = candidate.resolve(strict=True) if _inside_protected_git_root(canonical, root) or any( _inside_protected_git_root(ancestor.resolve(strict=True), root) for ancestor in candidate.parents ): - raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") + raise SystemExit(f"{setting} must stay outside the protected repository.") except OSError as error: - raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from error - if not _is_git_executable(candidate, canonical): - raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") + raise SystemExit(f"{setting} does not name an available executable.") from error + if not _is_native_executable(candidate, canonical): + raise SystemExit(f"{setting} does not name an available executable.") return configured entries: list[str] = [] executable: str | None = None - names = ("git.exe", "git.com") if sys.platform == "win32" else ("git",) + names = (f"{name}.exe", f"{name}.com") if sys.platform == "win32" else (name,) + if sys.platform == "win32": + path_keys = sorted(key for key in environment if key.upper() == "PATH") + if path_keys: + path = environment[path_keys[0]] + for key in path_keys: + del environment[key] + environment["PATH"] = path for entry in os.get_exec_path(environment): if not entry: continue @@ -198,7 +210,7 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | break except OSError: continue - if _is_git_executable(path, canonical): + if _is_native_executable(path, canonical): candidate = candidate or str(path) if not safe: continue @@ -208,6 +220,29 @@ def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | return executable +def _trusted_git_executable(target: Path, environment: dict[str, str]) -> str | None: + return _trusted_executable(target, environment, "git") + + +def ripgrep_command( + target: Path, + *args: str, + stdout: IO[bytes] | int = subprocess.PIPE, +) -> subprocess.CompletedProcess[bytes]: + environment = os.environ.copy() + executable = _trusted_executable(target, environment, "rg") + if executable is None: + raise FileNotFoundError("ripgrep is not available on a trusted PATH.") + return subprocess.run( + [executable, *args], + cwd=target, + stdout=stdout, + stderr=subprocess.PIPE, + env=environment, + check=False, + ) + + def git_command( target: Path, *args: str, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 5f7bb474..6d673705 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -658,6 +658,8 @@ export class CodexSecurity { const trustedPluginEnvironment = { ...ripgrep.environment, CODEX_SECURITY_GIT: git.executable ?? "", + // The Codex runtime can add its bundled tools to PATH after this point. + CODEX_SECURITY_RG: ripgrep.executable ?? undefined, }; checkOpen(); const scanOutputRoot = diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 10d11559..82942e15 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -33,9 +33,13 @@ export async function inspectTrustedExecutable( const root = await realpath(protectedRoot).catch(() => resolve(protectedRoot), ); - const path = Object.entries(environment).find( - ([name]) => name.toUpperCase() === "PATH", - )?.[1]; + const pathKeys = + process.platform === "win32" + ? Object.keys(environment) + .filter((name) => name.toUpperCase() === "PATH") + .sort() + : ["PATH"]; + const path = environment[pathKeys[0] ?? "PATH"]; // Match child_process lookup defaults without broadening an explicit PATH. const searchPath = path ?? @@ -94,9 +98,7 @@ export async function inspectTrustedExecutable( } } const sanitizedEnvironment = { ...environment }; - for (const name of Object.keys(sanitizedEnvironment)) { - if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; - } + for (const name of pathKeys) delete sanitizedEnvironment[name]; sanitizedEnvironment["PATH"] = entries .filter((entry) => !unsafeEntries.has(entry)) .join(delimiter); diff --git a/sdk/typescript/tests-ts/workbench-tool-environment.test.ts b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts new file mode 100644 index 00000000..94d4a065 --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts @@ -0,0 +1,227 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "bun:test"; +import { inspectTrustedExecutable } from "../src/trusted-executable.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +function childEnvironment(path: string): NodeJS.ProcessEnv { + return { + PATH: path, + ...(process.env["SystemRoot"] === undefined + ? {} + : { SystemRoot: process.env["SystemRoot"] }), + }; +} + +function inspectPlatformEnvironment( + platform: "linux" | "win32", + environment: Record, +) { + const result = spawnSync( + process.execPath, + [ + "-e", + ` + Object.defineProperty(process, "platform", { value: process.argv[1] }); + const { inspectTrustedExecutable } = await import(process.argv[2]); + console.log(JSON.stringify(await inspectTrustedExecutable( + "rg", JSON.parse(process.argv[3]), process.argv[4], + ))); + `, + platform, + fileURLToPath(new URL("../src/trusted-executable.ts", import.meta.url)), + JSON.stringify(environment), + process.cwd(), + ], + { + encoding: "utf8", + env: childEnvironment(process.env["PATH"] ?? ""), + }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as { + executable: string | null; + environment: Record; + }; +} + +function runPythonMocks(source: string): void { + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const result = spawnSync( + python!, + [ + "-I", + "-B", + "-c", + ` +import argparse, io, os, subprocess, sys +from pathlib import Path +from unittest.mock import patch +sys.path.insert(0, sys.argv[1]) +import workbench_target as workbench +${source} +`, + join(PLUGIN_ROOT, "scripts"), + ], + { encoding: "utf8", env: childEnvironment(dirname(python!)) }, + ); + expect(result.status, result.stderr).toBe(0); +} + +describe("workbench tool environments", () => { + test("preserves case-distinct POSIX environment keys", () => { + const environment = { + Path: "case-distinct value", + pAtH: "another value", + PATH: "", + KEEP: "yes", + }; + expect(inspectPlatformEnvironment("linux", environment)).toEqual({ + executable: null, + environment, + }); + }); + + test("normalizes Windows PATH aliases using the effective key", () => { + expect( + inspectPlatformEnvironment("win32", { + Path: "other value", + pAtH: "another value", + PATH: "", + KEEP: "yes", + }), + ).toEqual({ + executable: null, + environment: { KEEP: "yes", PATH: "" }, + }); + }); + + test("keeps default lookup separate from an explicitly empty PATH", async () => { + const defaultPath = + process.platform === "win32" + ? process.env["PATH"] ?? "" + : "/usr/bin:/bin"; + const expected = await inspectTrustedExecutable( + "rg", + { KEEP: "yes", PATH: defaultPath }, + process.cwd(), + ); + expect( + await inspectTrustedExecutable("rg", { KEEP: "yes" }, process.cwd()), + ).toEqual(expected); + expect( + await inspectTrustedExecutable( + "rg", + { KEEP: "yes", PATH: "" }, + process.cwd(), + ), + ).toEqual({ + executable: null, + environment: { KEEP: "yes", PATH: "" }, + }); + }); + + test("uses only a resolved ripgrep command and never spawns when unavailable", () => { + runPythonMocks(` +repository = Path.cwd() +directory = Path(sys.executable).parent +executable = directory / ("rg.exe" if sys.platform == "win32" else "rg") +completed = subprocess.CompletedProcess([str(executable)], 0, b"", b"") +with ( + patch.object(workbench, "_protected_git_root", return_value=repository), + patch.object(Path, "resolve", autospec=True, side_effect=lambda path, strict=False: path), + patch.object(workbench, "_inside_protected_git_root", return_value=False), + patch.object(workbench, "_is_native_executable", side_effect=lambda path, canonical: path == executable), + patch.object(workbench.subprocess, "run", return_value=completed) as run, +): + for environment in ( + {"PATH": str(directory)}, + {"PATH": "", "CODEX_SECURITY_RG": str(executable)}, + ): + with patch.dict(workbench.os.environ, environment, clear=True): + assert workbench.ripgrep_command(repository, "--files") is completed + assert run.call_args.args[0] == [str(executable), "--files"] + assert Path(run.call_args.args[0][0]).is_absolute() + assert run.call_args.kwargs["cwd"] == repository + assert run.call_args.kwargs["env"]["PATH"] == environment["PATH"] + + run.reset_mock() + for environment in ( + {"PATH": ""}, + {"PATH": str(directory), "CODEX_SECURITY_RG": ""}, + ): + with patch.dict(workbench.os.environ, environment, clear=True): + try: + workbench.ripgrep_command(repository, "--files") + except FileNotFoundError: + pass + else: + raise AssertionError("unavailable ripgrep was accepted") + run.assert_not_called() +`); + }); + + test("applies platform-specific PATH names in the Python resolver", () => { + runPythonMocks(` +repository = Path.cwd() +original = {"Path": "case-distinct value", "pAtH": "another value", "PATH": "", "KEEP": "yes"} +with patch.object(workbench, "_protected_git_root", return_value=repository): + for platform in ("linux", "win32"): + environment = dict(original) + with patch.object(workbench.sys, "platform", platform): + assert workbench._trusted_executable(repository, environment, "rg") is None + expected = original if platform == "linux" else {"PATH": "", "KEEP": "yes"} + assert environment == expected +`); + }); + + test("routes inventory and scoped ranking through the unavailable-tool guard", () => { + runPythonMocks(` +import generate_in_scope_files as inventory +import generate_rank_input as ranking +repository = Path.cwd().resolve() +with ( + patch.object(workbench, "_trusted_executable", return_value=None) as resolve, + patch.object(workbench.subprocess, "run") as run, + patch.dict(workbench.os.environ, {"PATH": ""}, clear=True), +): + with patch.object(inventory.tempfile, "TemporaryFile", return_value=io.BytesIO()): + try: + inventory.generate_in_scope_files(repository, ".", Path("unused-inventory")) + except inventory.InventoryError: + pass + else: + raise AssertionError("unavailable inventory tool was accepted") + + with ( + patch.object(Path, "is_dir", return_value=True), + patch.object(Path, "is_file", return_value=False), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "rglob", return_value=()), + patch.object(ranking, "load_scopes_file", return_value=["."]), + patch.object(ranking, "resolve_scope", return_value=repository), + patch.object(ranking, "git_directory_snapshot_paths", return_value=None), + patch.object(ranking, "write_jsonl") as write, + patch("builtins.print"), + ): + ranking.make_repo_scope_input(argparse.Namespace( + repo=str(repository), scopes_file="unused-scopes", out="unused-ranking", + )) + write.assert_called_once_with(Path("unused-ranking"), []) + assert resolve.call_count == 2 + run.assert_not_called() +`); + }); + + test("forwards both trusted tool bindings to the MCP host", () => { + const configuration = JSON.parse( + readFileSync(join(PLUGIN_ROOT, ".mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect(configuration.mcpServers["codex-security"]!.env_vars).toEqual( + expect.arrayContaining(["CODEX_SECURITY_GIT", "CODEX_SECURITY_RG"]), + ); + }); +}); From b70181855dff40acefec02a2cc59be461f5ef65d Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:40:19 -0700 Subject: [PATCH 07/11] fix(git): tolerate unavailable historical targets --- .../scripts/workbench_target.py | 24 ++++-- .../workbench-tool-environment.test.ts | 76 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index f1acdc69..93950d97 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -120,14 +120,20 @@ def _read_sized_nul_field( return output[offset:end], end + 1 -def _protected_git_root(target: Path) -> Path: - root = target.resolve() - for ancestor in (root, *root.parents): - try: - (ancestor / ".git").lstat() - except FileNotFoundError: - continue - root = ancestor +def _protected_git_root(target: Path) -> Path | None: + """Return the outermost repository root, or None for a stale target.""" + try: + root = target.resolve(strict=True) + if not stat.S_ISDIR(root.stat().st_mode): + return None + for ancestor in (root, *root.parents): + try: + (ancestor / ".git").lstat() + except FileNotFoundError: + continue + root = ancestor + except (FileNotFoundError, NotADirectoryError): + return None return root @@ -159,6 +165,8 @@ def _trusted_executable( name: str, ) -> str | None: root = _protected_git_root(target) + if root is None: + return None setting = f"CODEX_SECURITY_{name.upper()}" configured = environment.get(setting) if configured is not None: diff --git a/sdk/typescript/tests-ts/workbench-tool-environment.test.ts b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts index 94d4a065..bf3dfc34 100644 --- a/sdk/typescript/tests-ts/workbench-tool-environment.test.ts +++ b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts @@ -124,6 +124,82 @@ describe("workbench tool environments", () => { }); }); + test("treats stale target roots as unavailable without hiding other errors", () => { + runPythonMocks(` +import stat +from types import SimpleNamespace +repository = Path.cwd() +directory = SimpleNamespace(st_mode=stat.S_IFDIR) +with ( + patch.object(Path, "resolve", autospec=True, return_value=repository) as resolve, + patch.object(Path, "stat", return_value=directory) as metadata, + patch.object(Path, "lstat", side_effect=FileNotFoundError) as marker, +): + assert workbench._protected_git_root(repository) == repository + resolve.assert_called_once_with(repository, strict=True) + + metadata.return_value = SimpleNamespace(st_mode=stat.S_IFREG) + marker.reset_mock() + assert workbench._protected_git_root(repository) is None + marker.assert_not_called() + metadata.return_value = directory + + for error in (FileNotFoundError, NotADirectoryError): + for operation in (resolve, metadata): + operation.side_effect = error + assert workbench._protected_git_root(repository) is None + operation.side_effect = None + marker.side_effect = NotADirectoryError + assert workbench._protected_git_root(repository) is None + + for operation in (resolve, metadata, marker): + marker.side_effect = FileNotFoundError + failure = PermissionError("target metadata is unavailable") + operation.side_effect = failure + try: + workbench._protected_git_root(repository) + except PermissionError as error: + assert error is failure + else: + raise AssertionError("unrelated filesystem error was hidden") + operation.side_effect = None +`); + }); + + test("keeps stale history probes unavailable without spawning a tool", () => { + runPythonMocks(` +import workbench_scan_history as history +repository = Path.cwd() +before = {"target_id": "historical", "target_path": str(repository)} +after = {"target_id": "selected", "target_path": str(repository.parent)} +with ( + patch.object(workbench, "_protected_git_root", return_value=None), + patch.object(workbench, "_inside_protected_git_root") as inside, + patch.object(workbench.os, "get_exec_path") as lookup, + patch.object(workbench.subprocess, "run") as run, +): + for environment in ( + {"PATH": ""}, + {"PATH": "", "CODEX_SECURITY_GIT": sys.executable, "CODEX_SECURITY_RG": sys.executable}, + ): + with patch.dict(workbench.os.environ, environment, clear=True): + completed = workbench.git_command(repository, "rev-parse", "--show-toplevel", text=True) + assert (completed.returncode, completed.stdout, completed.stderr) == (127, "", "") + assert workbench.git_output(repository, "rev-parse", "--git-common-dir") is None + assert workbench.git_bytes(repository, "rev-parse", "--git-common-dir") is None + assert not history._same_repository(before, after, after_identity=(None, None)) + try: + workbench.ripgrep_command(repository, "--files") + except FileNotFoundError: + pass + else: + raise AssertionError("unavailable target was accepted") + inside.assert_not_called() + lookup.assert_not_called() + run.assert_not_called() +`); + }); + test("uses only a resolved ripgrep command and never spawns when unavailable", () => { runPythonMocks(` repository = Path.cwd() From 743e32f028ab8bb71e5d2607f1b30832269aabba Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:13:37 -0700 Subject: [PATCH 08/11] fix(runtime): stage packaged ripgrep for local installs --- sdk/typescript/src/api.ts | 45 ++++- sdk/typescript/src/runtime.ts | 89 ++++++++- sdk/typescript/tests-ts/api.test.ts | 232 ++++++++++++++++++++++++ sdk/typescript/tests-ts/runtime.test.ts | 182 +++++++++++++++++++ 4 files changed, 538 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 6d673705..462f82ab 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -100,6 +100,7 @@ import { resolvePluginPython, runWorkbench, setCodexSecurityCredentialLogout, + stageBundledRipgrep, type CodexCommand, type PluginInstall, type ProcessEnvironment, @@ -147,6 +148,7 @@ interface PreparedRuntime { codexHome: string; persistentCredentialHome?: boolean; bootstrapWorkspace?: string; + bundledRipgrep?: string; configPath?: string; deepScanConfigPath?: string; plugin: PluginInstall; @@ -296,6 +298,7 @@ interface ClientDependencies { prepareOutputDir?: typeof prepareOutputDir; repositoryRevision?: typeof repositoryRevision; resolveCodexCommand?: () => CodexCommand; + stageBundledRipgrep?: typeof stageBundledRipgrep; runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindings; } @@ -650,17 +653,49 @@ export class CodexSecurity { pluginEnvironment, protectedGitRoot, ); - const ripgrep = await inspectTrustedExecutable( + let ripgrep = await inspectTrustedExecutable( "rg", git.environment, protectedGitRoot, ); - const trustedPluginEnvironment = { + const ripgrepKeys = + process.platform === "win32" + ? Object.keys(pluginEnvironment) + .filter((name) => name.toUpperCase() === "CODEX_SECURITY_RG") + .sort() + : ["CODEX_SECURITY_RG"]; + const ripgrepDisabled = + pluginEnvironment[ripgrepKeys[0] ?? "CODEX_SECURITY_RG"] === ""; + if ( + !ripgrepDisabled && + ripgrep.executable === null && + runtime.bootstrapWorkspace !== undefined + ) { + const workspace = await realpath(runtime.bootstrapWorkspace); + requireOutputOutsideRepository(protectedGitRoot, workspace, "runtime"); + if (runtime.bundledRipgrep === undefined) { + const bundled = await ( + this.#dependencies.stageBundledRipgrep ?? stageBundledRipgrep + )(workspace, signal); + if (bundled !== null) runtime.bundledRipgrep = bundled; + } + if (runtime.bundledRipgrep !== undefined) { + ripgrep = await inspectTrustedExecutable( + runtime.bundledRipgrep, + ripgrep.environment, + protectedGitRoot, + ); + } + } + const trustedPluginEnvironment: ProcessEnvironment = { ...ripgrep.environment, - CODEX_SECURITY_GIT: git.executable ?? "", - // The Codex runtime can add its bundled tools to PATH after this point. - CODEX_SECURITY_RG: ripgrep.executable ?? undefined, }; + for (const name of ripgrepKeys) delete trustedPluginEnvironment[name]; + trustedPluginEnvironment["CODEX_SECURITY_GIT"] = git.executable ?? ""; + // The Codex runtime can add its bundled tools to PATH after this point. + trustedPluginEnvironment["CODEX_SECURITY_RG"] = ripgrepDisabled + ? "" + : ripgrep.executable ?? undefined; checkOpen(); const scanOutputRoot = requestedOutput === null && diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 13c41f2c..cf5000a9 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -2,6 +2,7 @@ import { execFile as execFileCallback, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants, existsSync, readdirSync, type Stats } from "node:fs"; import { + access, chmod, cp, copyFile, @@ -22,7 +23,16 @@ import { } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { createRequire } from "node:module"; -import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -1984,7 +1994,14 @@ export function resolveCodexCommand( ) { return { command: resolve(configured) }; } + return { command: resolveBundledCodexPackage().command }; +} +function resolveBundledCodexPackage(): { + packageRoot: string; + root: string; + command: string; +} { const platform = process.platform === "android" ? "linux" : process.platform; const packageName = `@openai/codex-${platform}-${process.arch}`; let packageJson: string; @@ -2000,13 +2017,14 @@ export function resolveCodexCommand( { cause: error }, ); } - const vendor = join(dirname(packageJson), "vendor"); + const packageRoot = dirname(packageJson); + const vendor = join(packageRoot, "vendor"); const target = readdirSync(vendor, { withFileTypes: true }).find((entry) => entry.isDirectory(), ); + const root = join(vendor, target?.name ?? ""); const command = join( - vendor, - target?.name ?? "", + root, "bin", process.platform === "win32" ? "codex.exe" : "codex", ); @@ -2015,7 +2033,68 @@ export function resolveCodexCommand( `The ${packageName} package does not contain the Codex executable. Reinstall @openai/codex with optional dependencies enabled, or set CODEX_CLI_PATH to an installed Codex executable.`, ); } - return { command }; + return { packageRoot, root, command }; +} + +export async function stageBundledRipgrep( + workspace: string, + signal?: AbortSignal, +): Promise { + throwIfSignalAborted(signal); + const name = process.platform === "win32" ? "rg.exe" : "rg"; + let source: string; + try { + // This is the running SDK's own dependency, not a tool found in the scan. + const bundled = resolveBundledCodexPackage(); + const packageRoot = await realpath(bundled.packageRoot); + const candidate = join(bundled.root, "codex-path", name); + const marker = await lstat(join(bundled.root, "codex-package.json")); + const metadata = await lstat(candidate); + source = await realpath(candidate); + const inside = relative(packageRoot, source); + if ( + !marker.isFile() || + marker.isSymbolicLink() || + !metadata.isFile() || + metadata.isSymbolicLink() || + inside === "" || + inside === ".." || + inside.startsWith(`..${sep}`) || + isAbsolute(inside) + ) { + return null; + } + await access( + source, + process.platform === "win32" ? constants.F_OK : constants.X_OK, + ); + } catch (error) { + const cause = error instanceof PluginBootstrapError ? error.cause : error; + if ( + (error instanceof PluginBootstrapError && cause === undefined) || + ["MODULE_NOT_FOUND", "ENOENT", "ENOTDIR", "EACCES"].includes( + nodeErrorCode(cause) ?? "", + ) + ) { + return null; + } + throw error; + } + + const destination = join(workspace, name); + let copied = false; + try { + throwIfSignalAborted(signal); + await copyFile(source, destination, constants.COPYFILE_EXCL); + copied = true; + if (process.platform !== "win32") await chmod(destination, 0o700); + const canonical = await realpath(destination); + throwIfSignalAborted(signal); + return canonical; + } catch (error) { + if (copied) await rm(destination, { force: true }).catch(() => undefined); + throw error; + } } export async function bootstrapPlugin( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 37896482..870e137a 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -49,7 +49,10 @@ import { resolveCodexCommand, runWorkbench, setCodexSecurityCredentialLogout, + type WorkbenchCommandOptions, } from "../src/runtime.js"; +import * as runtimeModule from "../src/runtime.js"; +import * as trustedExecutable from "../src/trusted-executable.js"; import { normalizeTarget } from "../src/targets.js"; import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; @@ -5455,6 +5458,235 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("binds staged bundled ripgrep only when a trusted host tool is unavailable", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "binds staged bundled ripgrep only when a trusted host tool is unavailable", + ) + ) { + return; + } + const originalTrusted = { ...trustedExecutable }; + const originalRuntime = { ...runtimeModule }; + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const inspected: [string, string][] = []; + let host: string | null = null; + let rejected: string | null = null; + mock.module("../src/trusted-executable.js", () => ({ + ...originalTrusted, + resolveTrustedExecutable: async () => null, + inspectTrustedExecutable: async ( + candidate: string, + environment: Record, + protectedRoot: string, + ) => { + inspected.push([candidate, protectedRoot]); + return { + executable: + candidate === "git" || candidate === rejected + ? null + : candidate === "rg" + ? host + : candidate, + environment: { ...environment, PATH: "" }, + }; + }, + })); + mock.module("../src/runtime.js", () => ({ + ...originalRuntime, + pluginExecutionEnvironment: ( + python: string, + environment: Record, + ) => ({ + ...environment, + PYTHON: python, + CODEX_CLI_PATH: process.execPath, + }), + })); + const cases: { + scenario: string; + platform?: NodeJS.Platform; + host: boolean; + bindings?: Record; + expected: "host" | "staged" | "disabled" | "missing"; + }[] = [ + { scenario: "host", host: true, expected: "host" }, + { scenario: "bundled", host: false, expected: "staged" }, + { scenario: "missing", host: false, expected: "missing" }, + { + scenario: "disabled", + host: true, + bindings: { CODEX_SECURITY_RG: "" }, + expected: "disabled", + }, + { scenario: "rejected-copy", host: false, expected: "missing" }, + { scenario: "overlapping-workspace", host: false, expected: "missing" }, + { + scenario: "case-distinct POSIX binding", + platform: "linux", + host: true, + bindings: { Codex_Security_Rg: "" }, + expected: "host", + }, + { + scenario: "Windows alias disable", + platform: "win32", + host: true, + bindings: { Codex_Security_Rg: "" }, + expected: "disabled", + }, + { + scenario: "Windows effective binding", + platform: "win32", + host: true, + bindings: { CODEX_SECURITY_RG: "previous", Codex_Security_Rg: "" }, + expected: "host", + }, + ]; + + try { + for (const entry of cases) { + const { scenario } = entry; + Object.defineProperty(process, "platform", { + value: entry.platform ?? originalPlatform.value, + }); + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const nextRepository = join(root, "next-repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const workspace = + scenario === "overlapping-workspace" + ? repository + : join(root, "bootstrap-workspace"); + for (const path of new Set([ + repository, + nextRepository, + codexHome, + scanDir, + workspace, + ])) { + await mkdir(path, { mode: 0o700 }); + } + const filename = process.platform === "win32" ? "rg.exe" : "rg"; + const staged = + scenario === "rejected-copy" + ? join(repository, filename) + : join(workspace, filename); + host = entry.host ? join(root, "host-tools", filename) : null; + rejected = scenario === "rejected-copy" ? staged : null; + inspected.length = 0; + const stageCalls: string[] = []; + const workbenchEnvironments: WorkbenchCommandOptions["environment"][] = + []; + const codexEnvironments: CodexOptions["env"][] = []; + const environment = { + PATH: "", + CODEX_CLI_PATH: process.execPath, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + OPENAI_API_KEY: "synthetic-key", + ...entry.bindings, + }; + const runtime = { + ...preparedRuntime(codexHome), + bootstrapWorkspace: workspace, + environment, + }; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => runtime, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => null, + resolveCodexCommand: () => ({ command: process.execPath }), + stageBundledRipgrep: async (path: string) => { + stageCalls.push(path); + return scenario === "missing" ? null : staged; + }, + runWorkbench: async ( + options: WorkbenchCommandOptions, + args: readonly string[], + ) => { + if (args[0] === "register-cli-scan") { + workbenchEnvironments.push(options.environment); + } + return mockWorkbench(args); + }, + createCodex: (options: CodexOptions) => { + codexEnvironments.push(options.env); + throw new Error("captured tool environment"); + }, + }, + ); + try { + if (scenario === "overlapping-workspace") { + await expect(client.run(repository)).rejects.toBeInstanceOf( + OutputInsideProtectedRootError, + ); + expect(stageCalls).toEqual([]); + expect(codexEnvironments).toEqual([]); + continue; + } + await expect(client.run(repository)).rejects.toThrow( + "captured tool environment", + ); + if (scenario === "bundled") { + await expect(client.run(nextRepository)).rejects.toThrow( + "captured tool environment", + ); + expect( + inspected.filter(([candidate]) => candidate === staged), + ).toEqual([ + [staged, repository], + [staged, nextRepository], + ]); + } + const expected = + entry.expected === "disabled" + ? "" + : entry.expected === "host" + ? host ?? undefined + : entry.expected === "staged" + ? staged + : undefined; + for (const selected of [ + ...workbenchEnvironments, + ...codexEnvironments, + ]) { + expect(selected?.["CODEX_SECURITY_RG"]).toBe(expected); + expect(selected?.["PATH"]).toBe(""); + expect(selected?.["Codex_Security_Rg"]).toBe( + process.platform === "win32" + ? undefined + : entry.bindings?.["Codex_Security_Rg"], + ); + } + expect(workbenchEnvironments).toHaveLength( + scenario === "bundled" ? 2 : 1, + ); + expect(codexEnvironments).toHaveLength( + scenario === "bundled" ? 2 : 1, + ); + expect(stageCalls).toEqual( + entry.host || entry.expected === "disabled" ? [] : [workspace], + ); + } finally { + await client.close(); + } + } + } finally { + Object.defineProperty(process, "platform", originalPlatform); + mock.module("../src/trusted-executable.js", () => originalTrusted); + mock.module("../src/runtime.js", () => originalRuntime); + } + }); + test("authenticates without initializing the plugin runtime", async () => { const root = await temporaryDirectory(); const stateDirectory = join(root, "state"); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70..02ae769f 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1,5 +1,6 @@ import { execFile, spawnSync } from "node:child_process"; import { existsSync, renameSync, symlinkSync } from "node:fs"; +import * as fsSync from "node:fs"; import { chmod, copyFile, @@ -17,6 +18,7 @@ import { writeFile, } from "node:fs/promises"; import * as fsPromises from "node:fs/promises"; +import * as nodeModule from "node:module"; import { tmpdir } from "node:os"; import { delimiter, @@ -70,6 +72,7 @@ import { requireTrustedOutputAncestor, runWorkbench, setCodexSecurityCredentialLogout, + stageBundledRipgrep, streamWindowsCredentialAclDescriptors, verifyStableWindowsCredentialDescendants, } from "../src/runtime.js"; @@ -1794,6 +1797,185 @@ describe("plugin runtime preparation", () => { ); }); + test("stages only package-owned native ripgrep and cleans failed copies", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "stages only package-owned native ripgrep and cleans failed copies", + ) + ) { + return; + } + const originalFs = { ...fsSync }; + const originalPromises = { ...fsPromises }; + const originalModule = { ...nodeModule }; + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const root = join(tmpdir(), "codex-security-package-mock"); + const codexPackageJson = join(root, "codex", "package.json"); + const packageRoot = join(root, "native-package"); + const nativePackageJson = join(packageRoot, "package.json"); + const vendor = join(packageRoot, "vendor"); + const bundle = join(vendor, "native-target"); + const workspace = join(root, "private-workspace"); + const marker = join(bundle, "codex-package.json"); + const filename = () => (process.platform === "win32" ? "rg.exe" : "rg"); + const source = () => join(bundle, "codex-path", filename()); + const destination = () => join(workspace, filename()); + const failure = (code: string) => Object.assign(new Error(code), { code }); + let scenario = "available"; + let cancelCopy: AbortController | undefined; + const resolutions: [string, string][] = []; + const copies: unknown[][] = []; + const modes: unknown[][] = []; + const removed: string[] = []; + const reset = (next: string) => { + scenario = next; + cancelCopy = undefined; + resolutions.length = 0; + copies.length = 0; + modes.length = 0; + removed.length = 0; + }; + mock.module("node:module", () => ({ + ...originalModule, + createRequire: (from: string | URL) => ({ + resolve: (specifier: string) => { + resolutions.push([String(from), specifier]); + if (scenario === "missing-package") throw failure("MODULE_NOT_FOUND"); + if (specifier === "@openai/codex/package.json") { + return codexPackageJson; + } + expect(String(from)).toBe(codexPackageJson); + expect(specifier).toBe( + `@openai/codex-${process.platform}-${process.arch}/package.json`, + ); + return nativePackageJson; + }, + }), + })); + mock.module("node:fs", () => ({ + ...originalFs, + readdirSync: (path: string) => { + expect(path).toBe(vendor); + return [{ name: "native-target", isDirectory: () => true }]; + }, + existsSync: () => scenario !== "missing-codex", + })); + mock.module("node:fs/promises", () => ({ + ...originalPromises, + realpath: async (path: string) => + scenario === "outside-package" && path === source() + ? join(root, "other", filename()) + : path, + lstat: async (path: string) => { + if ( + (scenario === "missing-rg" && path === source()) || + (scenario === "missing-marker" && path === marker) + ) { + throw failure("ENOENT"); + } + expect([source(), marker]).toContain(path); + return { + isFile: () => scenario !== "not-file" || path !== source(), + isSymbolicLink: () => scenario === "symlink" && path === source(), + }; + }, + access: async (path: string, mode: number) => { + expect(path).toBe(source()); + expect(mode).toBe( + process.platform === "win32" + ? originalFs.constants.F_OK + : originalFs.constants.X_OK, + ); + if (scenario === "not-executable") throw failure("EACCES"); + if (scenario === "io-error") throw failure("EIO"); + }, + copyFile: async (...args: unknown[]) => { + copies.push(args); + if (scenario === "existing-destination") throw failure("EEXIST"); + cancelCopy?.abort(new DOMException("canceled", "AbortError")); + }, + chmod: async (...args: unknown[]) => { + modes.push(args); + if (scenario === "chmod-error") throw failure("EACCES"); + }, + rm: async (path: string) => { + removed.push(path); + }, + })); + + try { + for (const platform of ["linux", "win32"] as const) { + Object.defineProperty(process, "platform", { value: platform }); + reset("available"); + expect(await stageBundledRipgrep(workspace)).toBe(destination()); + expect(resolutions).toEqual([ + [ + new URL("../src/runtime.ts", import.meta.url).href, + "@openai/codex/package.json", + ], + [ + codexPackageJson, + `@openai/codex-${platform}-${process.arch}/package.json`, + ], + ]); + expect(copies).toEqual([ + [source(), destination(), originalFs.constants.COPYFILE_EXCL], + ]); + expect(modes).toEqual( + platform === "win32" ? [] : [[destination(), 0o700]], + ); + expect(removed).toEqual([]); + } + + Object.defineProperty(process, "platform", { value: "linux" }); + for (const unavailable of [ + "missing-package", + "missing-codex", + "missing-marker", + "missing-rg", + "not-file", + "symlink", + "outside-package", + "not-executable", + ]) { + reset(unavailable); + expect(await stageBundledRipgrep(workspace)).toBeNull(); + expect(copies).toEqual([]); + } + reset("io-error"); + await expect(stageBundledRipgrep(workspace)).rejects.toMatchObject({ + code: "EIO", + }); + expect(copies).toEqual([]); + + reset("existing-destination"); + await expect(stageBundledRipgrep(workspace)).rejects.toMatchObject({ + code: "EEXIST", + }); + expect(removed).toEqual([]); + reset("chmod-error"); + await expect(stageBundledRipgrep(workspace)).rejects.toMatchObject({ + code: "EACCES", + }); + expect(removed).toEqual([destination()]); + reset("available"); + cancelCopy = new AbortController(); + await expect( + stageBundledRipgrep(workspace, cancelCopy.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(removed).toEqual([destination()]); + } finally { + Object.defineProperty(process, "platform", originalPlatform); + mock.module("node:module", () => originalModule); + mock.module("node:fs", () => originalFs); + mock.module("node:fs/promises", () => originalPromises); + } + }); + test("uses an explicit Codex executable override", () => { const executable = process.platform === "win32" ? "codex.exe" : "codex"; const configured = join(tmpdir(), "custom codex", executable); From c0142c5739652cff10467b6b5285ca5193c013a9 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:36:24 -0700 Subject: [PATCH 09/11] fix(api): preserve explicit Git disable bindings --- sdk/typescript/src/api.ts | 20 +++-- sdk/typescript/tests-ts/api.test.ts | 110 +++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 462f82ab..c01b79ec 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -658,12 +658,18 @@ export class CodexSecurity { git.environment, protectedGitRoot, ); - const ripgrepKeys = + const toolBindingKeys = ( + setting: "CODEX_SECURITY_GIT" | "CODEX_SECURITY_RG", + ): string[] => process.platform === "win32" ? Object.keys(pluginEnvironment) - .filter((name) => name.toUpperCase() === "CODEX_SECURITY_RG") + .filter((name) => name.toUpperCase() === setting) .sort() - : ["CODEX_SECURITY_RG"]; + : [setting]; + const gitKeys = toolBindingKeys("CODEX_SECURITY_GIT"); + const ripgrepKeys = toolBindingKeys("CODEX_SECURITY_RG"); + const gitDisabled = + pluginEnvironment[gitKeys[0] ?? "CODEX_SECURITY_GIT"] === ""; const ripgrepDisabled = pluginEnvironment[ripgrepKeys[0] ?? "CODEX_SECURITY_RG"] === ""; if ( @@ -690,8 +696,12 @@ export class CodexSecurity { const trustedPluginEnvironment: ProcessEnvironment = { ...ripgrep.environment, }; - for (const name of ripgrepKeys) delete trustedPluginEnvironment[name]; - trustedPluginEnvironment["CODEX_SECURITY_GIT"] = git.executable ?? ""; + for (const name of [...gitKeys, ...ripgrepKeys]) { + delete trustedPluginEnvironment[name]; + } + trustedPluginEnvironment["CODEX_SECURITY_GIT"] = gitDisabled + ? "" + : git.executable ?? ""; // The Codex runtime can add its bundled tools to PATH after this point. trustedPluginEnvironment["CODEX_SECURITY_RG"] = ripgrepDisabled ? "" diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 870e137a..27201b37 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4556,11 +4556,6 @@ describe("CodexSecurity orchestration", () => { startThread: () => ({ id: null, async runStreamed() { - expect( - existsSync( - join(credentialHome, ".codex-security-scan.lock"), - ), - ).toBe(false); activeScans += 1; maximumActiveScans = Math.max( maximumActiveScans, @@ -4585,6 +4580,11 @@ describe("CodexSecurity orchestration", () => { concurrentScans, new Promise((resolve) => setTimeout(resolve, 5_000)), ]); + expect( + existsSync( + join(credentialHome, ".codex-security-scan.lock"), + ), + ).toBe(false); const after = parseToml( await readFile(deepScanConfigPath!, "utf8"), ); @@ -5474,8 +5474,10 @@ describe("CodexSecurity orchestration", () => { "platform", )!; const inspected: [string, string][] = []; + let gitHost: string | null = null; let host: string | null = null; let rejected: string | null = null; + let sanitizedGitEnvironment: Record | undefined; mock.module("../src/trusted-executable.js", () => ({ ...originalTrusted, resolveTrustedExecutable: async () => null, @@ -5485,14 +5487,22 @@ describe("CodexSecurity orchestration", () => { protectedRoot: string, ) => { inspected.push([candidate, protectedRoot]); + const sanitizedEnvironment = { ...environment, PATH: "" }; + if (candidate === "git") { + sanitizedGitEnvironment = sanitizedEnvironment; + } else if (candidate === "rg") { + expect(sanitizedGitEnvironment).toBe(environment); + } return { executable: - candidate === "git" || candidate === rejected - ? null - : candidate === "rg" - ? host - : candidate, - environment: { ...environment, PATH: "" }, + candidate === "git" + ? gitHost + : candidate === rejected + ? null + : candidate === "rg" + ? host + : candidate, + environment: sanitizedEnvironment, }; }, })); @@ -5511,8 +5521,10 @@ describe("CodexSecurity orchestration", () => { scenario: string; platform?: NodeJS.Platform; host: boolean; + gitAvailable?: boolean; bindings?: Record; expected: "host" | "staged" | "disabled" | "missing"; + expectedGit?: "host" | "disabled"; }[] = [ { scenario: "host", host: true, expected: "host" }, { scenario: "bundled", host: false, expected: "staged" }, @@ -5546,6 +5558,66 @@ describe("CodexSecurity orchestration", () => { bindings: { CODEX_SECURITY_RG: "previous", Codex_Security_Rg: "" }, expected: "host", }, + { + scenario: "Git binding absent", + host: true, + gitAvailable: true, + expected: "host", + expectedGit: "host", + }, + { + scenario: "Git binding nonempty", + host: true, + gitAvailable: true, + bindings: { CODEX_SECURITY_GIT: "previous" }, + expected: "host", + expectedGit: "host", + }, + { + scenario: "Git binding disabled", + platform: "linux", + host: true, + gitAvailable: true, + bindings: { CODEX_SECURITY_GIT: "" }, + expected: "host", + expectedGit: "disabled", + }, + { + scenario: "case-distinct POSIX Git binding", + platform: "linux", + host: true, + gitAvailable: true, + bindings: { Codex_Security_Git: "" }, + expected: "host", + expectedGit: "host", + }, + { + scenario: "Windows Git alias disable", + platform: "win32", + host: true, + gitAvailable: true, + bindings: { Codex_Security_Git: "" }, + expected: "host", + expectedGit: "disabled", + }, + { + scenario: "Windows effective Git binding", + platform: "win32", + host: true, + gitAvailable: true, + bindings: { CODEX_SECURITY_GIT: "previous", Codex_Security_Git: "" }, + expected: "host", + expectedGit: "host", + }, + { + scenario: "Windows effective Git disable", + platform: "win32", + host: true, + gitAvailable: true, + bindings: { CODEX_SECURITY_GIT: "", Codex_Security_Git: "previous" }, + expected: "host", + expectedGit: "disabled", + }, ]; try { @@ -5573,6 +5645,13 @@ describe("CodexSecurity orchestration", () => { await mkdir(path, { mode: 0o700 }); } const filename = process.platform === "win32" ? "rg.exe" : "rg"; + gitHost = entry.gitAvailable + ? join( + root, + "host-tools", + process.platform === "win32" ? "git.exe" : "git", + ) + : null; const staged = scenario === "rejected-copy" ? join(repository, filename) @@ -5580,6 +5659,7 @@ describe("CodexSecurity orchestration", () => { host = entry.host ? join(root, "host-tools", filename) : null; rejected = scenario === "rejected-copy" ? staged : null; inspected.length = 0; + sanitizedGitEnvironment = undefined; const stageCalls: string[] = []; const workbenchEnvironments: WorkbenchCommandOptions["environment"][] = []; @@ -5659,8 +5739,16 @@ describe("CodexSecurity orchestration", () => { ...workbenchEnvironments, ...codexEnvironments, ]) { + expect(selected?.["CODEX_SECURITY_GIT"]).toBe( + entry.expectedGit === "host" ? gitHost ?? undefined : "", + ); expect(selected?.["CODEX_SECURITY_RG"]).toBe(expected); expect(selected?.["PATH"]).toBe(""); + expect(selected?.["Codex_Security_Git"]).toBe( + process.platform === "win32" + ? undefined + : entry.bindings?.["Codex_Security_Git"], + ); expect(selected?.["Codex_Security_Rg"]).toBe( process.platform === "win32" ? undefined From 62bfcd13d43785b578086d1ffe2cfdb90bc11c3a Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:48:54 -0700 Subject: [PATCH 10/11] fix(runtime): reject canonical Windows batch targets --- sdk/typescript/src/trusted-executable.ts | 3 + .../tests-ts/trusted-executable.test.ts | 152 +++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 82942e15..fece1394 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -85,6 +85,9 @@ export async function inspectTrustedExecutable( if (current.entry !== null) unsafeEntries.add(current.entry); continue; } + if (process.platform === "win32" && /\.(?:bat|cmd)$/iu.test(canonical)) { + continue; + } if (!current.runnable) continue; try { await access( diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index f1e06baa..7e4a4406 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { constants } from "node:fs"; import { chmod, mkdir, @@ -8,11 +9,16 @@ import { symlink, writeFile, } from "node:fs/promises"; +import * as fsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, test } from "bun:test"; -import { resolveTrustedExecutable } from "../src/trusted-executable.js"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + inspectTrustedExecutable, + resolveTrustedExecutable, +} from "../src/trusted-executable.js"; +import { runMockInSubprocess } from "./support/isolated-mock.js"; const temporaryDirectories: string[] = []; @@ -146,6 +152,148 @@ describe("trusted executable resolution", () => { }, ); + test("rejects canonical Windows batch targets without rejecting native aliases", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "rejects canonical Windows batch targets without rejecting native aliases", + ) + ) { + return; + } + const originalPromises = { ...fsPromises }; + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const root = join(tmpdir(), "trusted-executable-metadata-mock"); + const repository = join(root, "repository"); + const first = join(root, "first"); + const second = join(root, "second"); + const firstExe = join(first, "rg.exe"); + const firstCom = join(first, "rg.com"); + const secondExe = join(second, "rg.exe"); + const native = join(root, "native-target"); + const command = join(root, "target.CmD"); + const batch = join(root, "target.BaT"); + let paths = new Map(); + let files = new Set(); + const accesses: [string, number][] = []; + const missing = () => + Object.assign(new Error("missing mock path"), { code: "ENOENT" }); + mock.module("node:fs/promises", () => ({ + ...originalPromises, + realpath: async (path: string) => { + const canonical = paths.get(path); + if (canonical === undefined) throw missing(); + return canonical; + }, + access: async (path: string, mode: number) => { + accesses.push([path, mode]); + if (!files.has(path)) throw missing(); + }, + stat: async (path: string) => ({ isFile: () => files.has(path) }), + })); + const cases: { + platform: NodeJS.Platform; + entries: string[]; + targets: [string, string][]; + executable: string | null; + keptEntries?: string[]; + }[] = [ + { + platform: "win32", + entries: [first], + targets: [[firstExe, command]], + executable: null, + }, + { + platform: "win32", + entries: [first], + targets: [[firstExe, batch]], + executable: null, + }, + { + platform: "win32", + entries: [first, second], + targets: [ + [firstExe, command], + [secondExe, native], + ], + executable: secondExe, + }, + { + platform: "win32", + entries: [first], + targets: [[firstExe, native]], + executable: firstExe, + }, + { + platform: "win32", + entries: [first], + targets: [[firstCom, native]], + executable: firstCom, + }, + { + platform: "win32", + entries: [first, second], + targets: [ + [firstExe, join(repository, "target.cmd")], + [secondExe, native], + ], + executable: secondExe, + keptEntries: [second], + }, + { + platform: "linux", + entries: [first], + targets: [[join(first, "rg"), command]], + executable: join(first, "rg"), + }, + ]; + + try { + for (const entry of cases) { + Object.defineProperty(process, "platform", { value: entry.platform }); + paths = new Map([ + [repository, repository], + ...entry.entries.map((path): [string, string] => [path, path]), + ...entry.targets, + ]); + files = new Set(entry.targets.map(([, canonical]) => canonical)); + accesses.length = 0; + expect( + await inspectTrustedExecutable( + "rg", + { PATH: entry.entries.join(delimiter), KEEP: "ok" }, + repository, + ), + ).toEqual({ + executable: entry.executable, + environment: { + KEEP: "ok", + PATH: (entry.keptEntries ?? entry.entries).join(delimiter), + }, + }); + expect( + accesses.every( + ([, mode]) => + mode === + (entry.platform === "win32" ? constants.F_OK : constants.X_OK), + ), + ).toBe(true); + if (entry.platform === "win32") { + expect(accesses.some(([path]) => /\.(?:bat|cmd)$/iu.test(path))).toBe( + false, + ); + } + } + } finally { + Object.defineProperty(process, "platform", originalPlatform); + mock.module("node:fs/promises", () => originalPromises); + } + }); + test("selects runnable Windows executables ahead of extensionless and batch files", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From b83d18e5748c92846c391cb062941e742e2e27dd Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:50:22 -0700 Subject: [PATCH 11/11] test: preserve Python shim startup environment --- sdk/typescript/tests-ts/workbench-tool-environment.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/workbench-tool-environment.test.ts b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts index bf3dfc34..27deb564 100644 --- a/sdk/typescript/tests-ts/workbench-tool-environment.test.ts +++ b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts @@ -57,16 +57,19 @@ function runPythonMocks(source: string): void { "-B", "-c", ` -import argparse, io, os, subprocess, sys +import argparse, io, json, os, subprocess, sys from pathlib import Path from unittest.mock import patch +os.environ.clear() +os.environ.update(json.loads(sys.argv[2])) sys.path.insert(0, sys.argv[1]) import workbench_target as workbench ${source} `, join(PLUGIN_ROOT, "scripts"), + JSON.stringify(childEnvironment(dirname(python!))), ], - { encoding: "utf8", env: childEnvironment(dirname(python!)) }, + { encoding: "utf8" }, ); expect(result.status, result.stderr).toBe(0); }