diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index cd1d449b5..38f1b1ef8 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -506,9 +506,10 @@ These container settings are distinct from standalone CLI flags and interactive discovery's `GH_HOST`. Variables such as `CODEX_SECURITY_SCAN_ID`, `CODEX_SECURITY_SCAN_DIR`, -`CODEX_SECURITY_PLUGIN_ROOT`, `CODEX_SECURITY_CONFIG_PATH`, and -`CODEX_SECURITY_TARGET_PATHS_FILE` are generated by an active scan. They are -internal runtime data, not supported user configuration. +`CODEX_SECURITY_PLUGIN_ROOT`, `CODEX_SECURITY_CONFIG_PATH`, +`CODEX_SECURITY_GIT`, and `CODEX_SECURITY_TARGET_PATHS_FILE` are generated by +an active scan. They are internal runtime data, not supported user +configuration. Use `--provider openrouter` to send inference through OpenRouter. Set `OPENROUTER_API_KEY` and specify a supported model with `--model`. diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 76688fe96..05e5e355d 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.22", + "version": "0.1.29", "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 dfea7dda8..08886b5d2 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -33,6 +33,7 @@ "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "PYTHON", "PYTHONUTF8", + "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 42bb48dc6..ecffa1c29 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_blob_bytes, git_command + class InventoryError(ValueError): """Raised when the repository, scope, or inventory cannot be used safely.""" @@ -127,20 +131,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, + result = git_command( + repository, + "diff", + "--no-ext-diff", + "--no-textconv", + "--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 @@ -166,7 +168,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, @@ -174,8 +175,6 @@ def generate_diff_in_scope_files( is_binary_sample, preview_for, ) - from workbench_target import git_blob_bytes - rows: list[bytes] = [] try: changed = ( diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 8c4dd3722..374176b25 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", @@ -626,20 +626,18 @@ 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", + "--no-ext-diff", + "--no-textconv", + "--name-status", + "-z", + "--diff-filter=ACMRD", + *diff_args, + text=False, ) + result.check_returncode() fields = result.stdout.split(b"\0") if fields and not fields[-1]: fields.pop() @@ -663,11 +661,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, - check=True, + untracked = git_command( + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", + text=False, ) + untracked.check_returncode() combined = dict(staged) combined.update(unstaged) combined.update( diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 2019115f8..ad94495cc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -1,6 +1,9 @@ """Shared constants for the Codex Security workbench.""" import argparse +import os +import sys +from pathlib import Path MODES = ("diff", "standard", "deep") DIFF_TARGET_KINDS = ("working_tree", "commit", "range") @@ -70,6 +73,59 @@ EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +def _protected_repository_root(target: Path) -> Path: + root = target.resolve() + if root.is_file(): + root = root.parent + protected = root + for ancestor in (root, *root.parents): + try: + (ancestor / ".git").lstat() + except FileNotFoundError: + continue + protected = ancestor + return protected + + +def trusted_git_executable(protected_root: Path) -> str | None: + """Return the host-selected Git executable without searching ``PATH``.""" + configured = os.environ.get("CODEX_SECURITY_GIT") + if not configured: + return None + + candidate = Path(configured) + if not candidate.is_absolute(): + raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + + try: + invocation = Path(os.path.abspath(candidate)) + canonical = candidate.resolve(strict=True) + repository = _protected_repository_root(protected_root) + except (OSError, RuntimeError): + return None + + windows = sys.platform == "win32" + native_windows_suffixes = {".exe", ".com"} + if ( + not canonical.is_file() + or not os.access(canonical, os.F_OK if windows else os.X_OK) + or ( + windows + and ( + candidate.suffix.lower() not in native_windows_suffixes + or canonical.suffix.lower() in {".bat", ".cmd"} + ) + ) + ): + return None + if any( + path == repository or repository in path.parents + for path in (invocation, canonical) + ): + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") + return str(invocation) + + def main() -> None: argparse.ArgumentParser(description=__doc__).parse_args() diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 3e26d5acd..ecfd5181e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -16,7 +16,7 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) from filesystem_identity import stored_filesystem_identity_matches -from workbench_constants import GIT_REPOSITORY_ENVIRONMENT +from workbench_constants import GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable def git_output( @@ -134,12 +134,15 @@ def git_command( for name in GIT_REPOSITORY_ENVIRONMENT: environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" + executable = trusted_git_executable(target) # Repository-local config is untrusted; fsmonitor may name an executable hook. command = [ - "git", + executable or "git", "-c", "core.fsmonitor=false", "-c", + f"core.hooksPath={os.devnull}", + "-c", "i18n.logOutputEncoding=UTF-8", "-C", str(target), @@ -147,6 +150,9 @@ def git_command( if git_dir is not None and work_tree is not None: command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] + if executable is None: + empty_output = "" if text else b"" + return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) try: return subprocess.run( full_command, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index dc58bb92f..97d1b3320 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -113,7 +113,7 @@ import { importAmbientAuth, prepareCodexSecurityCredentialHome, preserveCodexSecurityPluginRegistration, - pluginExecutionEnvironment, + pluginExecutionEnvironmentWithGit, planOutputArchive, prepareOutputDir, preparePersistentOutputRoot, @@ -134,6 +134,7 @@ import { enclosingGitWorktreeRoot, normalizeRepository, normalizeTarget, + outermostGitMarkerRoot, repositoryRevision, resolveRepositoryPath, type NormalizedTarget, @@ -143,6 +144,10 @@ import { validateCommittedDiffCheckout, validateMode, } from "./targets.js"; +import { + inspectTrustedExecutable, + type InspectedExecutable, +} from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -547,6 +552,28 @@ export class CodexSecurity { python, } = session; releaseCredentialHome = session.releaseCredentialHome; + const pluginEnvironment = selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ); + const gitProtectedRoot = await outermostGitMarkerRoot(repo, signal); + let git = await inspectTrustedExecutable( + "git", + pluginEnvironment, + gitProtectedRoot, + ); + for (const source of knowledgeBase?.sources ?? []) { + const sourceDirectory = (await lstat(source)).isDirectory() + ? source + : dirname(source); + git = await inspectTrustedExecutable( + "git", + git.environment, + await outermostGitMarkerRoot(sourceDirectory, signal), + ); + } + checkOpen(); const deepScanConfigPath = mode === "deep" ? runtime.deepScanConfigPath ?? @@ -765,13 +792,10 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment( - runtime.environment, - options.auth, - modelProvider, - ), + ...pluginEnvironment, CODEX_SECURITY_STATE_DIR: stateDirectory, }, + git, signal, failureMessage: "Could not save the Codex Security scan", }; @@ -975,6 +999,7 @@ export class CodexSecurity { session, runtimePaths, options.auth, + git, ); const thread = codex.startThread({ workingDirectory: scanDir, @@ -1646,6 +1671,7 @@ export class CodexSecurity { session: PreparedSession, runtimePaths: Record, auth: ScanAuthMode = "auto", + git: InspectedExecutable, ): { codex: CodexClientLike; environment: ProcessEnvironment } { const { runtime, @@ -1656,11 +1682,12 @@ export class CodexSecurity { sessionConfig, } = session; const environment = { - ...pluginExecutionEnvironment( + ...pluginExecutionEnvironmentWithGit( python, withoutCodexHome( selectedScanEnvironment(runtime.environment, auth, modelProvider), ), + git, ), ...(externalProvider === null ? {} diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 15063adf6..d93dcf225 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -56,7 +56,12 @@ import { errorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + inspectTrustedExecutable, + resolveTrustedExecutable, + type InspectedExecutable, +} from "./trusted-executable.js"; +import { outermostGitMarkerRoot } from "./targets.js"; import { isWindowsUnsafePathComponent, windowsUnsafePathComponent, @@ -114,6 +119,7 @@ export interface WorkbenchCommandOptions { python: string; pluginRoot: string; environment: ProcessEnvironment; + git?: InspectedExecutable; signal?: AbortSignal; failureMessage?: string; } @@ -1375,8 +1381,21 @@ export async function runWorkbench( ): Promise { let stdout: string; try { + const git = + options.git ?? + (await inspectTrustedExecutable( + "git", + options.environment, + await outermostGitMarkerRoot(process.cwd(), options.signal), + )); const environment = Object.fromEntries( - Object.entries(options.environment).filter( + Object.entries( + pluginExecutionEnvironmentWithGit( + options.python, + options.environment, + git, + ), + ).filter( ([name]) => name.toUpperCase() !== "OPENAI_API_KEY" && name.toUpperCase() !== "CODEX_API_KEY" && @@ -1394,7 +1413,7 @@ export async function runWorkbench( join(options.pluginRoot, "scripts", "workbench_db.py"), ...args, ], - pythonUtf8Environment(environment), + environment, input, options.signal, ); @@ -2342,11 +2361,27 @@ export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, ): ProcessEnvironment { - return { - ...pythonUtf8Environment(environment), - PYTHON: python, - CODEX_CLI_PATH: resolveCodexCommand(environment).command, - }; + const result = pythonUtf8Environment(environment); + result["PYTHON"] = python; + result["CODEX_CLI_PATH"] = resolveCodexCommand(environment).command; + return result; +} + +export function pluginExecutionEnvironmentWithGit( + python: string, + environment: ProcessEnvironment, + git: InspectedExecutable, +): ProcessEnvironment { + const result = pluginExecutionEnvironment(python, environment); + for (const name of Object.keys(result)) { + const normalized = name.toUpperCase(); + if (normalized === "PATH" || normalized === "CODEX_SECURITY_GIT") { + delete result[name]; + } + } + result["PATH"] = git.environment["PATH"] ?? ""; + result["CODEX_SECURITY_GIT"] = git.executable ?? ""; + return result; } export function pythonUtf8Environment( diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 975e43e5f..686fd9215 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -439,7 +439,7 @@ async function gitOutput( return stdout.replace(process.platform === "win32" ? /\r?\n$/u : /\n$/u, ""); } -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 aa0cac47f..2277ee942 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -15,11 +15,31 @@ export interface TrustedExecutable { environment: Record; } +export interface InspectedExecutable { + executable: string | null; + environment: Record; +} + export async function resolveTrustedExecutable( candidate: string, 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 inspectTrustedExecutable( + candidate: string, + environment: Readonly>, + protectedRoot: string, +): Promise { const root = await realpath(protectedRoot).catch(() => resolve(protectedRoot), ); @@ -83,6 +103,9 @@ export async function resolveTrustedExecutable( 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( @@ -95,7 +118,6 @@ export async function resolveTrustedExecutable( continue; } } - if (executable === null) return null; const sanitizedEnvironment = { ...environment }; for (const name of Object.keys(sanitizedEnvironment)) { diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 241666585..d1a958531 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,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.22" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.29" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 35e85126c..b91a777be 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1,5 +1,6 @@ import { appendFile, + chmod, copyFile, cp, mkdir, @@ -15,7 +16,7 @@ import * as fsPromises from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { basename, join } from "node:path"; +import { basename, delimiter, dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; import { afterEach, describe, expect, mock, test } from "bun:test"; @@ -44,7 +45,11 @@ import { type JsonObject, } from "../src/config.js"; import { estimateScanCost, type ScanCost } from "../src/cost.js"; -import { resolveCodexCommand, runWorkbench } from "../src/runtime.js"; +import { + resolveCodexCommand, + runWorkbench, + type WorkbenchCommandOptions, +} from "../src/runtime.js"; import { normalizeTarget } from "../src/targets.js"; import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; @@ -1788,16 +1793,19 @@ describe("CodexSecurity orchestration", () => { const warningDetails: Array<{ kind: "target_changed" } | undefined> = []; const reconnects: Array<[number, number]> = []; const commands: Array = []; + const workbenchGitExecutables: Array = []; let registrationInput: string | undefined; const completionWarning = "Repository HEAD changed while the scan was running; results were saved for the original revision."; const recoveryWarning = "Recovered finding: normalized its semantic anchor."; + const git = Bun.which("git"); + expect(git).not.toBeNull(); const client = new TestClient( { codexOverrides: { model: "replay-model" } }, { - environment: { PATH: "/usr/bin", OPENAI_API_KEY: "" }, + environment: { PATH: dirname(git!), OPENAI_API_KEY: "" }, prepareRuntime: async () => ({ codexHome, plugin: { @@ -1811,7 +1819,7 @@ describe("CodexSecurity orchestration", () => { environment: { CODEX_HOME: codexHome, Codex_Home: "/credentials/case-variant-must-not-reach-shell", - PATH: "/usr/bin", + PATH: dirname(git!), GITHUB_TOKEN: "must-not-reach-shell", AWS_SECRET_ACCESS_KEY: "must-not-reach-shell", }, @@ -1821,10 +1829,13 @@ describe("CodexSecurity orchestration", () => { prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", runWorkbench: async ( - _options: unknown, + workbenchOptions: WorkbenchCommandOptions, args: readonly string[], input?: string, ): Promise => { + workbenchGitExecutables.push( + workbenchOptions.git?.executable ?? null, + ); commands.push(args); if (args[0] === "register-cli-scan") { registrationInput = input; @@ -1905,7 +1916,11 @@ describe("CodexSecurity orchestration", () => { expect(startedAt.endsWith("Z")).toBe(true); expect(Date.parse(startedAt)).toBeGreaterThanOrEqual(scanStartedAt); expect(Date.parse(startedAt)).toBeLessThanOrEqual(Date.now()); - expect((codexOptions as CodexOptions | null)?.env).toMatchObject({ + const codexEnvironment = (codexOptions as CodexOptions | null)?.env; + const codexGit = codexEnvironment?.["CODEX_SECURITY_GIT"]; + if (typeof codexGit !== "string") + throw new Error("missing trusted Git binding"); + expect(codexEnvironment).toMatchObject({ CODEX_HOME: codexHome, PYTHON: "/managed/python", CODEX_SECURITY_STARTED_AT: startedAt, @@ -1915,7 +1930,9 @@ describe("CodexSecurity orchestration", () => { CODEX_SECURITY_TARGET_DISPLAY_NAME: basename(repository), CODEX_SECURITY_TARGET_KIND: "git_revision", CODEX_SECURITY_TARGET_REVISION: "deadbeef", + CODEX_SECURITY_GIT: expect.stringMatching(/git(?:\.exe)?$/iu), }); + expect(new Set(workbenchGitExecutables)).toEqual(new Set([codexGit])); expect((codexOptions as CodexOptions | null)?.env).not.toHaveProperty( "CODEX_SECURITY_TARGET_SNAPSHOT_DIGEST", ); @@ -3854,36 +3871,56 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("provides authoritative knowledge-base context without retaining its documents", async () => { + test("protects cross-repository knowledge-base context without retaining its documents", async () => { const scanPrompt = "Review the synthetic authorization boundary."; const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); const scanDir = join(root, "scan"); const knowledgeBase = join(root, "system-knowledge"); + const knowledgeBaseBin = join(knowledgeBase, "node_modules", ".bin"); + const knowledgeBaseGit = join( + knowledgeBaseBin, + process.platform === "win32" ? "git.exe" : "git", + ); + const trustedGit = Bun.which("git"); + expect(trustedGit).not.toBeNull(); + if (trustedGit === null) return; + const trustedBin = await realpath(dirname(trustedGit)); + const expectedGit = join(trustedBin, basename(trustedGit)); const context = "Internet-facing billing API; prioritize authorization bypasses.\n"; - await mkdir(repository); + await mkdir(join(repository, ".git"), { recursive: true }); await mkdir(codexHome); await mkdir(scanDir, { mode: 0o700 }); - await mkdir(knowledgeBase); + await mkdir(join(knowledgeBase, ".git"), { recursive: true }); + await mkdir(knowledgeBaseBin, { recursive: true }); + await writeFile(knowledgeBaseGit, "knowledge-base-controlled Git\n"); + if (process.platform !== "win32") await chmod(knowledgeBaseGit, 0o700); await writeFile(join(knowledgeBase, "system-threats.md"), context); let knowledgeDirectory = ""; + let workbenchGit = ""; let prompt = ""; let recipe: unknown; const client = new TestClient( {}, { environment: {}, - prepareRuntime: async () => preparedRuntime(codexHome), + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { + PATH: [knowledgeBaseBin, trustedBin].join(delimiter), + }, + }), resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", runWorkbench: async ( - _options: unknown, + workbenchOptions: WorkbenchCommandOptions, args: readonly string[], input?: string, ): Promise => { + workbenchGit = workbenchOptions.git?.executable ?? ""; if (args[0] === "get-scan-feedback") { return { scanId: "scan_example_001", @@ -3922,6 +3959,7 @@ describe("CodexSecurity orchestration", () => { }), ).resolves.toMatchObject({ threadId: "thread-1" }); expect(existsSync(knowledgeDirectory)).toBe(false); + expect(workbenchGit).toBe(expectedGit); expect(prompt).toContain( shellEnvironmentReference("CODEX_SECURITY_KNOWLEDGE_BASE"), ); @@ -4629,6 +4667,10 @@ describe("CodexSecurity orchestration", () => { const runtime = preparedRuntime(codexHome); return { ...runtime, + environment: { + ...runtime.environment, + PATH: process.env["PATH"] ?? "", + }, plugin: { ...runtime.plugin, installedRoot: join( @@ -4893,7 +4935,13 @@ describe("CodexSecurity orchestration", () => { "--out", output, ], - { stdio: "pipe" }, + { + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git") ?? "", + }, + stdio: "pipe", + }, ); return (await readFile(output, "utf8")) .trimEnd() diff --git a/sdk/typescript/tests-ts/compact-diff-scan.test.ts b/sdk/typescript/tests-ts/compact-diff-scan.test.ts index 106fd38e5..ab53e1400 100644 --- a/sdk/typescript/tests-ts/compact-diff-scan.test.ts +++ b/sdk/typescript/tests-ts/compact-diff-scan.test.ts @@ -50,6 +50,13 @@ function git(repository: string, ...args: string[]): string { ).trim(); } +function gitExecutable(): string { + const executable = Bun.which("git"); + expect(executable).not.toBeNull(); + if (executable === null) throw new Error("Git is required for diff tests."); + return executable; +} + function writeSource( repository: string, path: string, @@ -67,7 +74,10 @@ function python(script: string, ...args: string[]) { return spawnSync( command!, ["-B", join(PLUGIN_ROOT, "scripts", script), ...args], - { encoding: "utf8" }, + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_GIT: gitExecutable() }, + }, ); } @@ -87,6 +97,7 @@ async function startMcp(root: string) { { env: { ...process.env, + CODEX_SECURITY_GIT: gitExecutable(), CODEX_SECURITY_SCAN_ROOT: join(root, "scans"), CODEX_SECURITY_STATE_DIR: join(root, "state"), PYTHONDONTWRITEBYTECODE: "1", @@ -296,7 +307,14 @@ describe("compact diff scan", () => { "--out", output, ], - { encoding: "utf8", env: { ...process.env, PYTHONUTF8: "0" } }, + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_GIT: gitExecutable(), + PYTHONUTF8: "0", + }, + }, ); expect(result.status, result.stderr).toBe(0); diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 19e120300..dd3d87653 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,5 +1,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { + chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -14,6 +16,7 @@ import { afterEach, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; const temporaryRoots: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; function pythonExecutable(): string | null { return ( @@ -24,6 +27,23 @@ function pythonExecutable(): string | null { ); } +function trustedGit(): string { + const executable = Bun.which("git"); + expect(executable).not.toBeNull(); + if (executable === null) throw new Error("Git is required for diff tests."); + return realpathSync(executable); +} + +function pythonEnvironment( + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return { + ...process.env, + CODEX_SECURITY_GIT: trustedGit(), + ...overrides, + }; +} + afterEach(() => { for (const root of temporaryRoots.splice(0)) { rmSync(root, { recursive: true, force: true }); @@ -109,7 +129,7 @@ test("diff previews stay inside the selected repository", () => { "--out", output, ], - { encoding: "utf8" }, + { encoding: "utf8", env: pythonEnvironment() }, ); expect(result.status, result.stderr).toBe(0); @@ -181,7 +201,7 @@ test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { "--out", output, ], - { encoding: "utf8" }, + { encoding: "utf8", env: pythonEnvironment() }, ); const probeSource = [ "import json, pathlib, sys", @@ -205,7 +225,7 @@ test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { repository, legacyHead, ], - { encoding: "utf8" }, + { encoding: "utf8", env: pythonEnvironment() }, ); expect(rank.status, `${rank.stderr}\n${String(rank.error ?? "")}`).toBe(0); @@ -230,3 +250,94 @@ test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { diff: { kind: "commit", baseRevision: head, headRevision: legacyHead }, }); }); + +testPosix( + "uses only the host-selected Git executable for rank and inventory helpers", + () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-host-git-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "tools"); + const shim = join(shimDirectory, "git"); + const marker = join(root, "shim-ran"); + mkdirSync(shimDirectory, { recursive: true }); + git(repository, "init", "-q"); + writeFileSync(join(repository, "source.py"), "value = 1\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(repository, "source.py"), "value = 2\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "head"); + const head = git(repository, "rev-parse", "HEAD"); + writeFileSync(shim, '#!/bin/sh\n: > "$GIT_SHIM_MARKER"\nexit 99\n'); + chmodSync(shim, 0o700); + + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const rankOutput = join(root, "rank.jsonl"); + const inventoryOutput = join(root, "inventory.txt"); + const environment = { + PATH: `${shimDirectory}:${process.env["PATH"] ?? ""}`, + GIT_SHIM_MARKER: marker, + }; + const rankArguments = [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + base, + "--head", + head, + "--out", + rankOutput, + ]; + + const unavailable = spawnSync(python!, rankArguments, { + encoding: "utf8", + env: pythonEnvironment({ ...environment, CODEX_SECURITY_GIT: "" }), + }); + expect(unavailable.status).not.toBe(0); + + const rejected = spawnSync(python!, rankArguments, { + encoding: "utf8", + env: pythonEnvironment({ ...environment, CODEX_SECURITY_GIT: shim }), + }); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("outside the protected repository"); + + const rank = spawnSync(python!, rankArguments, { + encoding: "utf8", + env: pythonEnvironment(environment), + }); + expect(rank.status, rank.stderr).toBe(0); + + const inventory = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--diff-base", + base, + "--diff-head", + head, + "--out", + inventoryOutput, + ], + { encoding: "utf8", env: pythonEnvironment(environment) }, + ); + expect(inventory.status, inventory.stderr).toBe(0); + expect(readFileSync(inventoryOutput, "utf8")).toBe("source.py\n"); + expect(existsSync(marker)).toBe(false); + }, +); diff --git a/sdk/typescript/tests-ts/mcp-launcher.test.ts b/sdk/typescript/tests-ts/mcp-launcher.test.ts index 047c5e2ed..38c8c0e62 100644 --- a/sdk/typescript/tests-ts/mcp-launcher.test.ts +++ b/sdk/typescript/tests-ts/mcp-launcher.test.ts @@ -28,6 +28,7 @@ test("starts the packaged MCP server with managed Node and an empty PATH", async env_vars: string[]; }; expect(config.env_vars).toContain("CODEX_MCP_NODE_PATH"); + expect(config.env_vars).toContain("CODEX_SECURITY_GIT"); let managedNode = node; const marker = join(root, "managed-node-used"); if (process.platform !== "win32") { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 7dbf80c8f..8a83d3cb8 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -64,6 +64,7 @@ import { prepareCodexSecurityCredentialHome, preparePersistentOutputRoot, preserveCodexSecurityPluginRegistration, + pluginExecutionEnvironmentWithGit, requirePrivateCredentialHome, requirePrivateCredentialFile, requirePrivateOutputDirectory, @@ -3956,6 +3957,71 @@ describe("runtime directories and plugin Python boundary", () => { expect(result["details"]).toHaveLength(5 * 1024 * 1024); }); + testPosix( + "does not execute repo-root Git shims from a nested workbench directory", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const nestedDirectory = join(repository, "src"); + const untrustedBin = join(repository, "node_modules", ".bin"); + const pluginRoot = join(root, "plugin"); + const marker = join(root, "repository-git-executed"); + const untrustedGit = join(untrustedBin, "git"); + const git = Bun.which("git"); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(git).not.toBeNull(); + expect(python).not.toBeNull(); + if (git === null || python === null) return; + + await mkdir(untrustedBin, { recursive: true }); + await mkdir(join(repository, ".git")); + await mkdir(nestedDirectory); + await mkdir(join(pluginRoot, "scripts"), { recursive: true }); + await writeFile( + untrustedGit, + `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, + { mode: 0o700 }, + ); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, subprocess", + "assert os.environ.get('GIT_SSH_COMMAND') == 'synthetic-ssh'", + "git = os.environ['CODEX_SECURITY_GIT']", + "completed = subprocess.run([git, '--version'], check=True, capture_output=True, text=True)", + "print(json.dumps({'git': git, 'path': os.environ.get('PATH'), 'version': completed.stdout.strip()}))", + ].join("\n"), + ); + + const currentDirectory = spyOn(process, "cwd").mockReturnValue( + nestedDirectory, + ); + try { + const result = await runWorkbench( + { + python, + pluginRoot, + environment: { + PATH: [untrustedBin, dirname(git)].join(delimiter), + CODEX_SECURITY_GIT: untrustedGit, + GIT_SSH_COMMAND: "synthetic-ssh", + }, + }, + ["test-command"], + ); + + expect(await realpath(String(result["git"]))).toBe(await realpath(git)); + expect(result["version"]).toMatch(/^git version /u); + expect(String(result["path"]).split(delimiter)).not.toContain( + untrustedBin, + ); + expect(existsSync(marker)).toBe(false); + } finally { + currentDirectory.mockRestore(); + } + }, + ); + test("upgrades colliding legacy execution-profile and public CLI migrations", async () => { const root = await temporaryDirectory("codex-security-legacy-migrations-"); const repository = join(root, "repository"); @@ -4895,6 +4961,59 @@ describe("runtime directories and plugin Python boundary", () => { ).toBe(await realpath(interpreter!)); }); + test("binds the plugin environment to inspected Git", () => { + const python = join( + tmpdir(), + process.platform === "win32" ? "python.exe" : "python", + ); + const codex = join( + tmpdir(), + process.platform === "win32" ? "codex.exe" : "codex", + ); + const environment = pluginExecutionEnvironmentWithGit( + python, + { + Path: join(tmpdir(), "repository-bin"), + PATH: join(tmpdir(), "other-repository-bin"), + Git_Config_Count: "1", + GIT_DIR: join(tmpdir(), "repository", ".git"), + Codex_Security_Git: join(tmpdir(), "repository-bin", "git"), + pythonutf8: "0", + CODEX_CLI_PATH: codex, + TEST: "1", + }, + { + executable: join(tmpdir(), "trusted-bin", "git"), + environment: { PATH: join(tmpdir(), "trusted-bin") }, + }, + ); + + expect(environment).toEqual({ + CODEX_SECURITY_GIT: join(tmpdir(), "trusted-bin", "git"), + CODEX_CLI_PATH: codex, + GIT_DIR: join(tmpdir(), "repository", ".git"), + Git_Config_Count: "1", + PATH: join(tmpdir(), "trusted-bin"), + PYTHON: python, + PYTHONUTF8: "1", + TEST: "1", + }); + expect( + pluginExecutionEnvironmentWithGit( + python, + { Path: join(tmpdir(), "repository-bin"), GIT_DIR: ".git" }, + { executable: null, environment: { PATH: "" } }, + ), + ).toEqual({ + CODEX_SECURITY_GIT: "", + CODEX_CLI_PATH: resolveCodexCommand().command, + GIT_DIR: ".git", + PATH: "", + PYTHON: python, + PYTHONUTF8: "1", + }); + }); + test.skipIf(process.platform !== "win32")( "runs plugin helpers with UTF-8 standard streams", async () => { diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index e2c184ae6..34c8228ec 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -465,7 +465,13 @@ describe("malformed scan artifact recovery", () => { fixture.repository, join(fixture.stateDir, "checkout"), ], - { encoding: "utf8" }, + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git") ?? "", + }, + }, ); expect(copied.status, copied.stderr).toBe(0); } diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index 8c74d8e5a..d900f9ccb 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -12,7 +12,10 @@ 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 { + inspectTrustedExecutable, + resolveTrustedExecutable, +} from "../src/trusted-executable.js"; const temporaryDirectories: string[] = []; @@ -113,6 +116,40 @@ describe("trusted executable resolution", () => { }); }); + test("sanitizes repository-linked PATH entries when no trusted executable exists", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const repositoryTools = join(repository, "tools"); + const linkedExecutable = join(root, "linked-executable"); + const safe = join(root, "safe"); + const executable = process.platform === "win32" ? "git.exe" : "git"; + await Promise.all([ + mkdir(repositoryTools, { recursive: true }), + mkdir(linkedExecutable), + mkdir(safe), + ]); + await writeFile(join(repositoryTools, executable), "untrusted executable"); + await symlink( + join(repositoryTools, executable), + join(linkedExecutable, executable), + "file", + ); + + await expect( + inspectTrustedExecutable( + "git", + { + PATH: [linkedExecutable, safe].join(delimiter), + KEEP: "ok", + }, + repository, + ), + ).resolves.toEqual({ + executable: null, + environment: { KEEP: "ok", PATH: safe }, + }); + }); + test.skipIf(process.platform === "win32")( "preserves the invocation name of a trusted symlinked executable", async () => { @@ -254,6 +291,31 @@ describe("trusted executable resolution", () => { ).toBeNull(); }); + test("rejects Windows batch targets without requiring a canonical native suffix", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const trusted = join(root, "trusted"); + await Promise.all([mkdir(repository), mkdir(trusted)]); + await Promise.all([ + writeFile(join(trusted, "git.cmd"), "batch"), + writeFile(join(trusted, "tool"), "extensionless"), + ]); + await Promise.all([ + symlink(join(trusted, "git.cmd"), join(trusted, "git.exe"), "file"), + symlink(join(trusted, "tool"), join(trusted, "tool.exe"), "file"), + ]); + + expect( + await resolveWindowsExecutable("git", trusted, repository), + ).toBeNull(); + expect(await resolveWindowsExecutable("tool", trusted, repository)).toEqual( + { + executable: join(trusted, "tool.exe"), + environment: { KEEP: "ok", PATH: trusted }, + }, + ); + }); + test("removes PATH entries containing repository-linked Windows shims", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts index 67dbddeab..53bb50058 100644 --- a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts +++ b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts @@ -118,7 +118,11 @@ function runPythonProbe( const result = Bun.spawnSync( [python, "-I", "-B", "-c", program, join(PLUGIN_ROOT, "scripts"), ...args], - { stdout: "pipe", stderr: "pipe" }, + { + env: { ...process.env, CODEX_SECURITY_GIT: Bun.which("git") ?? "" }, + stdout: "pipe", + stderr: "pipe", + }, ); expect(new TextDecoder().decode(result.stderr)).toBe(""); expect(result.exitCode).toBe(0); @@ -157,6 +161,93 @@ describe("bundled workbench canonical paths", () => { ).toEqual({ subjects: 2, locales: 2 }); }); + testPosix( + "rejects Windows batch Git targets after canonicalization", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const native = join(root, "git.com"); + const batch = join(root, "git.cmd"); + const extensionless = join(root, "git-native"); + const nativeAlias = join(root, "trusted.exe"); + const batchAlias = join(root, "untrusted.exe"); + const extensionlessAlias = join(root, "native-alias.exe"); + await mkdir(join(repository, ".git"), { recursive: true }); + await Promise.all([ + writeFile(native, "native\n"), + writeFile(batch, "batch\n"), + writeFile(extensionless, "native\n"), + ]); + await Promise.all([ + symlink(native, nativeAlias), + symlink(batch, batchAlias), + symlink(extensionless, extensionlessAlias), + ]); + + expect( + runPythonProbe( + [ + "import json, os, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_constants as constants", + "constants.sys.platform = 'win32'", + "repository = Path(sys.argv[2])", + "def inspect(candidate):", + " os.environ['CODEX_SECURITY_GIT'] = candidate", + " try: return {'path': constants.trusted_git_executable(repository)}", + " except SystemExit as error: return {'error': str(error)}", + "print(json.dumps({name: inspect(path) for name, path in {'native': sys.argv[3], 'nativeAlias': sys.argv[4], 'batch': sys.argv[5], 'batchAlias': sys.argv[6], 'extensionless': sys.argv[7], 'extensionlessAlias': sys.argv[8]}.items()}))", + ].join("\n"), + repository, + native, + nativeAlias, + batch, + batchAlias, + extensionless, + extensionlessAlias, + ), + ).toEqual({ + native: { path: native }, + nativeAlias: { path: nativeAlias }, + batch: { path: null }, + batchAlias: { path: null }, + extensionless: { path: null }, + extensionlessAlias: { path: extensionlessAlias }, + }); + }, + ); + + test("treats a stale trusted Git binding as unavailable", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stale = join( + root, + process.platform === "win32" ? "missing-git.exe" : "missing-git", + ); + await mkdir(repository); + + expect( + runPythonProbe( + [ + "import json, os, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_target as target", + "repository = Path(sys.argv[2])", + "os.environ['CODEX_SECURITY_GIT'] = sys.argv[3]", + "def unexpected_spawn(*args, **kwargs):", + " raise AssertionError('stale Git binding reached subprocess.run')", + "target.subprocess.run = unexpected_spawn", + "result = target.git_command(repository, 'status', text=True)", + "print(json.dumps({'returncode': result.returncode, 'stdout': result.stdout, 'command': result.args[0]}))", + ].join("\n"), + repository, + stale, + ), + ).toEqual({ returncode: 127, stdout: "", command: "git" }); + }); + testPosix( "rejects private scan directories under insecure shared parents", async () => { diff --git a/sdk/typescript/tests-ts/workbench-nested-git-utf8.test.ts b/sdk/typescript/tests-ts/workbench-nested-git-utf8.test.ts index 8cab07e2a..b51e4aae0 100644 --- a/sdk/typescript/tests-ts/workbench-nested-git-utf8.test.ts +++ b/sdk/typescript/tests-ts/workbench-nested-git-utf8.test.ts @@ -73,7 +73,11 @@ test("writes nested Git pointers as UTF-8 independently of the locale", () => { repository, checkout, ], - { encoding: "utf8", windowsHide: true }, + { + encoding: "utf8", + env: { ...process.env, CODEX_SECURITY_GIT: Bun.which("git") ?? "" }, + windowsHide: true, + }, ); expect(result.status, result.stderr).toBe(0); diff --git a/sdk/typescript/tests-ts/workbench-windows-compatibility.test.ts b/sdk/typescript/tests-ts/workbench-windows-compatibility.test.ts index d731259da..c680a8818 100644 --- a/sdk/typescript/tests-ts/workbench-windows-compatibility.test.ts +++ b/sdk/typescript/tests-ts/workbench-windows-compatibility.test.ts @@ -20,7 +20,11 @@ function probe(program: string, ...args: string[]): void { throw new Error("Python is required for workbench tests."); const result = Bun.spawnSync( [python, "-I", "-B", "-c", program, join(PLUGIN_ROOT, "scripts"), ...args], - { stdout: "pipe", stderr: "pipe" }, + { + env: { ...process.env, CODEX_SECURITY_GIT: Bun.which("git") ?? "" }, + stdout: "pipe", + stderr: "pipe", + }, ); expect(result.exitCode, result.stderr.toString()).toBe(0); }