diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 4015ea149..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.21", + "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 9a4fc836e..72f155a66 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -28,6 +28,8 @@ "AWS_CONTAINER_AUTHORIZATION_TOKEN", "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 6449bf747..158b1ca63 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,20 +104,16 @@ 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", + "--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 @@ -146,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 92018d74b..b7a277028 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_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) @@ -608,21 +611,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 +644,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 05faf6e03..20ae116ef 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import errno import hashlib import os import shutil @@ -11,7 +12,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)) @@ -120,6 +121,146 @@ def _read_sized_nul_field( return output[offset:end], end + 1 +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, RuntimeError): + # pathlib raised RuntimeError for symlink loops before Python 3.13. + return None + except OSError as error: + if error.errno != errno.ELOOP: + raise + return None + 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 _is_native_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_executable( + target: Path, + environment: dict[str, str], + 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: + if not configured: + return None + candidate = Path(configured) + if not candidate.is_absolute(): + raise SystemExit(f"{setting} must name an absolute trusted executable.") + if sys.platform == "win32" and not os.path.splitext(candidate.name)[1]: + candidate = Path(f"{candidate}.exe") + 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(f"{setting} must stay outside the protected repository.") + except (OSError, RuntimeError) as error: + 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 = (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 sys.platform == "win32" and entry.startswith('"') and entry.endswith('"'): + entry = entry[1:-1] + if not entry: + continue + try: + directory = Path(entry).resolve(strict=True) + if _inside_protected_git_root(directory, root): + continue + except (OSError, RuntimeError): + 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, RuntimeError): + continue + if _is_native_executable(path, canonical): + 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 _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, @@ -134,25 +275,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 85d1b6577..c02d8eec4 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -61,6 +61,7 @@ import { import { AuthenticationRequiredError, CodexSecurityError, + ConfigurationError, IncompleteScanError, OutputDirectoryError, errorMessage, @@ -108,12 +109,14 @@ import { prepareOutputDir, preparePersistentOutputRoot, requireModelSafeOutputDir, + requireOutputOutsideRepositories, requireOutputOutsideRepository, resolveCodexCommand, resolvePluginPath, resolvePluginPython, runWorkbench, setCodexSecurityCredentialLogout, + stageBundledRipgrep, type CodexCommand, type PluginInstall, type ProcessEnvironment, @@ -124,7 +127,10 @@ import { enclosingGitWorktreeRoot, normalizeRepository, normalizeTarget, + outermostGitMarkerRoot, + protectedGitInputRoots, repositoryRevision, + resolveGitCommand, resolveRepositoryPath, type NormalizedTarget, type ScanMode, @@ -133,6 +139,12 @@ import { validateCommittedDiffCheckout, validateMode, } from "./targets.js"; +import { + executableBinding, + inspectTrustedExecutable, + isAbsoluteExecutablePath, + type InspectedExecutable, +} from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -155,6 +167,7 @@ interface PreparedRuntime { codexHome: string; persistentCredentialHome?: boolean; bootstrapWorkspace?: string; + bundledRipgrep?: string; configPath?: string; deepScanConfigPath?: string; plugin: PluginInstall; @@ -297,6 +310,8 @@ export interface ScanPreflight extends DeepScanOptions { interface LocalScanInputs extends Omit { protectedRoot: string; + protectedGitRoots: readonly string[]; + gitCommand: InspectedExecutable; stateDirectory: string; } @@ -324,6 +339,7 @@ interface ClientDependencies { prepareOutputDir?: typeof prepareOutputDir; repositoryRevision?: typeof repositoryRevision; resolveCodexCommand?: () => CodexCommand; + stageBundledRipgrep?: typeof stageBundledRipgrep; runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindings; } @@ -493,6 +509,8 @@ export class CodexSecurity { mode, outputDir: requestedOutput, protectedRoot, + protectedGitRoots, + gitCommand, stateDirectory, } = await this.#validateLocalInputs(repository, options, signal); checkOpen(); @@ -529,7 +547,7 @@ export class CodexSecurity { runtimeHome, effectiveConfig, preflightConfig, - modelProvider, + scanEnvironment, authentication, approvalPolicy, python, @@ -548,6 +566,85 @@ export class CodexSecurity { signal, ); } + const pluginEnvironment = { + ...withoutCodexHome(scanEnvironment), + CODEX_HOME: runtime.codexHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }; + const { keys: gitKeys } = executableBinding( + pluginEnvironment, + "CODEX_SECURITY_GIT", + ); + const { keys: ripgrepKeys, value: configuredRipgrep } = executableBinding( + pluginEnvironment, + "CODEX_SECURITY_RG", + ); + // Sanitize the runtime PATH without changing the Git selected at preflight. + const git = await inspectTrustedExecutable( + "git", + pluginEnvironment, + protectedGitRoots, + ); + let ripgrep = await inspectTrustedExecutable( + "rg", + git.environment, + protectedGitRoots, + ); + if (configuredRipgrep !== undefined && configuredRipgrep !== "") { + if (!isAbsoluteExecutablePath(configuredRipgrep)) { + throw new ConfigurationError( + "CODEX_SECURITY_RG must name an absolute trusted executable.", + ); + } + const inspected = await inspectTrustedExecutable( + configuredRipgrep, + ripgrep.environment, + protectedGitRoots, + { preserveInvocation: true }, + ); + if (inspected.executable === null) { + throw new ConfigurationError( + "CODEX_SECURITY_RG does not name an available executable.", + ); + } + ripgrep = inspected; + } + if ( + configuredRipgrep === undefined && + ripgrep.executable === null && + runtime.bootstrapWorkspace !== undefined + ) { + const workspace = await realpath(runtime.bootstrapWorkspace); + requireOutputOutsideRepositories( + protectedGitRoots, + 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, + protectedGitRoots, + ); + } + } + const trustedPluginEnvironment: ProcessEnvironment = { + ...ripgrep.environment, + }; + for (const name of [...gitKeys, ...ripgrepKeys]) { + delete trustedPluginEnvironment[name]; + } + trustedPluginEnvironment["CODEX_SECURITY_GIT"] = + gitCommand.executable ?? ""; + // The Codex runtime can add its bundled tools to PATH after this point. + trustedPluginEnvironment["CODEX_SECURITY_RG"] = + configuredRipgrep === "" ? "" : ripgrep.executable ?? undefined; checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -624,7 +721,7 @@ export class CodexSecurity { repository: repo, repositoryRevision: await ( this.#dependencies.repositoryRevision ?? repositoryRevision - )(repo, signal), + )(repo, signal, gitCommand), target: normalized, mode, pluginVersion: runtime.plugin.version, @@ -739,11 +836,7 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment( - runtime.environment, - options.auth, - modelProvider, - ), + ...trustedPluginEnvironment, CODEX_SECURITY_STATE_DIR: stateDirectory, }, signal, @@ -938,6 +1031,7 @@ export class CodexSecurity { session, runtimePaths, options.auth, + trustedPluginEnvironment, ); const thread = codex.startThread({ workingDirectory: scanDir, @@ -1548,6 +1642,7 @@ export class CodexSecurity { session: PreparedSession, runtimePaths: Record, auth: ScanAuthMode = "auto", + processEnvironment?: ProcessEnvironment, ): { codex: CodexClientLike; environment: ProcessEnvironment } { const { runtime, @@ -1561,7 +1656,8 @@ export class CodexSecurity { ...pluginExecutionEnvironment( python, withoutCodexHome( - selectedScanEnvironment(runtime.environment, auth, modelProvider), + processEnvironment ?? + selectedScanEnvironment(runtime.environment, auth, modelProvider), ), ), ...(externalProvider === null @@ -1894,14 +1990,36 @@ export class CodexSecurity { throwIfAborted(signal); const requestedTarget = options.target ?? "repository"; validatedGitEnvironment(this.#dependencies.environment); - const normalized = await normalizeTarget(repo, requestedTarget, signal); + const protectedGitRoot = await outermostGitMarkerRoot(repo, signal); + const protectedGitRoots = await protectedGitInputRoots( + [repositoryPath, ...(options.knowledgeBasePaths ?? [])], + signal, + ); + const gitCommand = await resolveGitCommand( + this.#dependencies.environment, + protectedGitRoots, + ); + const normalized = await normalizeTarget( + repo, + requestedTarget, + signal, + gitCommand, + ); throwIfAborted(signal); const mode = options.mode ?? "standard"; validateMode(normalized, mode); - await validateCommittedDiffCheckout(repo, normalized, signal); + await validateCommittedDiffCheckout(repo, normalized, signal, gitCommand); throwIfAborted(signal); + const enclosingRoot = + (await enclosingGitWorktreeRoot(repo, signal, gitCommand)) ?? + protectedGitRoot; + const repositoryRelative = relative(enclosingRoot, repo); const protectedRoot = - (await enclosingGitWorktreeRoot(repo, signal)) ?? repo; + repositoryRelative !== ".." && + !repositoryRelative.startsWith(`..${sep}`) && + !isAbsolute(repositoryRelative) + ? enclosingRoot + : repo; const requestedOutput = await validateOutputDir( options.outputDir, options.archiveExisting, @@ -1934,6 +2052,8 @@ export class CodexSecurity { mode, outputDir: requestedOutput, protectedRoot, + protectedGitRoots, + gitCommand, stateDirectory, }; } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 5f7c03fd8..6d35171d1 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -131,6 +131,7 @@ import { type ScanMode, type ScanTarget, } from "./targets.js"; +import { executableBinding } from "./trusted-executable.js"; import { BUNDLED_PLUGIN_VERSION, checkForUpdate, @@ -1072,7 +1073,7 @@ async function writeCliOutput( export function exportEnvironment( environment: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - return Object.fromEntries( + const result = Object.fromEntries( [ "PATH", "Path", @@ -1091,6 +1092,9 @@ export function exportEnvironment( .filter((key) => environment[key] !== undefined) .map((key) => [key, environment[key]]), ); + const git = executableBinding(environment, "CODEX_SECURITY_GIT").value; + if (git !== undefined) result["CODEX_SECURITY_GIT"] = git; + return result; } export async function main( diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 63f3a7909..044e51c7e 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -23,8 +23,11 @@ import type { ScanCost } from "./cost.js"; import { safeErrorMessage, ScanCostLimitExceededError } from "./errors.js"; import type { CoverageDocument } from "./models.js"; import { requireSecureOutputAncestry } from "./runtime.js"; -import type { ScanMode } from "./targets.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + protectedGitInputRoots, + resolveGitCommand, + type ScanMode, +} from "./targets.js"; const execFile = promisify(execFileCallback); const REQUIRED_ARTIFACTS = [ @@ -128,7 +131,7 @@ async function runCampaign( output: string, ): Promise { const ledger = join(output, "results.jsonl"); - await ensureOutputDirectory(join(output, "checkouts")); + const checkoutRoot = await ensureOutputDirectory(join(output, "checkouts")); await ensureOutputDirectory(join(output, "artifacts")); await ensureManifest(join(output, "manifest.json"), tasks, options); const receipts = await readReceipts(ledger); @@ -194,6 +197,18 @@ async function runCampaign( }; } + const gitRoots = await protectedGitInputRoots( + [ + options.inputPath, + ...tasks + .filter((task) => isAbsolute(task.repository)) + .map((task) => task.repository), + ...(options.knowledgeBasePaths ?? []), + ], + options.signal, + ); + gitRoots.push(checkoutRoot); + let next = 0; let failed = 0; const worker = async ( @@ -207,7 +222,7 @@ async function runCampaign( for (let retry = 0; retry < options.maxAttempts; retry += 1) { options.signal?.throwIfAborted(); attempt += 1; - const checkout = join(output, "checkouts", task.id); + const checkout = join(checkoutRoot, task.id); const scanDir = join( output, "artifacts", @@ -228,6 +243,7 @@ async function runCampaign( await checkoutRevision( task, checkout, + gitRoots, options.signal, options.githubHost, ); @@ -788,6 +804,7 @@ function normalizeRepository(repository: string, directory: string): string { async function checkoutRevision( task: MultiscanTask, path: string, + protectedRoots: readonly string[], signal?: AbortSignal, githubHost?: string, ): Promise { @@ -804,19 +821,18 @@ async function checkoutRevision( } environment["GIT_TERMINAL_PROMPT"] = "0"; environment["GIT_LFS_SKIP_SMUDGE"] = "1"; - const command = await resolveTrustedExecutable( - "git", + const { executable, environment: gitEnvironment } = await resolveGitCommand( environment, - resolve(process.cwd()), + protectedRoots, ); - if (command === null) { + if (executable === null) { throw new Error("Git is not available on a trusted PATH."); } const git = async (...args: string[]): Promise => { // Use the resolved absolute path so Windows PATHEXT cannot prefer a // .bat/.cmd shim over the trusted executable selected above. const result = await execFile( - command.executable, + executable, [ "-c", "core.hooksPath=/dev/null", @@ -825,7 +841,7 @@ async function checkoutRevision( path, ...args, ], - { env: command.environment, signal }, + { env: gitEnvironment, signal }, ); return result.stdout.trim(); }; diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 81b2a2379..b0e21082c 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, @@ -2029,7 +2030,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; @@ -2045,13 +2053,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", ); @@ -2060,7 +2069,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/src/targets.ts b/sdk/typescript/src/targets.ts index 5c8ab0888..a25bde2d1 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -1,11 +1,16 @@ import { execFile as execFileCallback } from "node:child_process"; import { existsSync } from "node:fs"; -import { lstat, realpath, stat } from "node:fs/promises"; +import { lstat, readlink, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import { InvalidTargetError } from "./errors.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { ConfigurationError, InvalidTargetError } from "./errors.js"; +import { + executableBinding, + inspectTrustedExecutable, + isAbsoluteExecutablePath, + type InspectedExecutable, +} from "./trusted-executable.js"; const execFile = promisify(execFileCallback); const UNSUPPORTED_GIT_ENVIRONMENT = new Set([ @@ -136,16 +141,25 @@ export function resolveRepositoryPath(repository: string): string { export async function enclosingGitWorktreeRoot( repository: string, signal?: AbortSignal, + gitCommand?: InspectedExecutable, ): Promise { try { + const command = + gitCommand ?? + (await resolveGitCommand( + process.env, + await outermostGitMarkerRoot(repository, signal), + )); const root = await gitOutput( repository, ["rev-parse", "--show-toplevel"], + command, signal, ); return await abortable(() => realpath(root), signal); - } catch { + } catch (error) { throwIfAborted(signal); + if (error instanceof ConfigurationError) throw error; return null; } } @@ -166,10 +180,23 @@ export function validatedGitEnvironment( } } +export function normalizeTarget( + repository: string, + target: ScanTarget, + signal?: AbortSignal, +): Promise; +/** @internal */ +export function normalizeTarget( + repository: string, + target: ScanTarget, + signal: AbortSignal | undefined, + gitCommand: InspectedExecutable, +): Promise; export async function normalizeTarget( repository: string, target: ScanTarget, signal?: AbortSignal, + gitCommand?: InspectedExecutable, ): Promise { const root = await normalizeRepository(repository, signal); throwIfAborted(signal); @@ -199,8 +226,14 @@ export async function normalizeTarget( "Working-tree targets cannot specify a head ref.", ); } - await requireGitRepository(root, signal); - const base = await resolveGitRef(root, target.base, signal); + const command = + gitCommand ?? + (await resolveGitCommand( + process.env, + await outermostGitMarkerRoot(root, signal), + )); + await requireGitRepository(root, command, signal); + const base = await resolveGitRef(root, target.base, command, signal); if (target.kind === "refs") { const head = target.head; if (typeof head !== "string" || head.length === 0) { @@ -212,7 +245,7 @@ export async function normalizeTarget( kind: "refs", paths: [], base, - head: await resolveGitRef(root, head, signal), + head: await resolveGitRef(root, head, command, signal), baseRef: target.base, headRef: head, }; @@ -221,7 +254,7 @@ export async function normalizeTarget( kind: "working_tree", paths: [], base, - head: await resolveGitRef(root, "HEAD", signal), + head: await resolveGitRef(root, "HEAD", command, signal), baseRef: target.base, headRef: "HEAD", }; @@ -288,10 +321,17 @@ export async function validateCommittedDiffCheckout( repository: string, target: NormalizedTarget, signal?: AbortSignal, + gitCommand?: InspectedExecutable, ): Promise { if (target.kind !== "refs") return; - const checkoutHead = await resolveGitRef(repository, "HEAD", signal); + const command = + gitCommand ?? + (await resolveGitCommand( + process.env, + await outermostGitMarkerRoot(repository, signal), + )); + const checkoutHead = await resolveGitRef(repository, "HEAD", command, signal); if (checkoutHead !== target.head) { throw new InvalidTargetError( `Committed-diff scans require the repository checkout to match the requested head revision. Checkout HEAD is ${checkoutHead}; requested head is ${target.head}. Check out the requested head and retry.`, @@ -301,6 +341,7 @@ export async function validateCommittedDiffCheckout( const status = await gitOutput( repository, ["status", "--porcelain=v1", "--untracked-files=all"], + command, signal, ); if (status.length !== 0) { @@ -309,7 +350,12 @@ export async function validateCommittedDiffCheckout( ); } - const tracked = await gitOutput(repository, ["ls-files", "-t", "-z"], signal); + const tracked = await gitOutput( + repository, + ["ls-files", "-t", "-z"], + command, + signal, + ); if (tracked.split("\0").some((entry) => entry.startsWith("S "))) { throw new InvalidTargetError( "Committed-diff scans require a full repository checkout. Sparse checkouts are not supported; materialize skipped tracked files and retry.", @@ -331,24 +377,44 @@ export function validateMode(target: NormalizedTarget, mode: ScanMode): void { } } +export function repositoryRevision( + repository: string, + signal?: AbortSignal, +): Promise; +/** @internal */ +export function repositoryRevision( + repository: string, + signal: AbortSignal | undefined, + gitCommand: InspectedExecutable, +): Promise; export async function repositoryRevision( repository: string, signal?: AbortSignal, + gitCommand?: InspectedExecutable, ): Promise { try { + const command = + gitCommand ?? + (await resolveGitCommand( + process.env, + await outermostGitMarkerRoot(repository, signal), + )); return await gitOutput( repository, ["rev-parse", "--verify", "HEAD^{commit}"], + command, signal, ); - } catch { + } catch (error) { throwIfAborted(signal); + if (error instanceof ConfigurationError) throw error; return null; } } async function requireGitRepository( repository: string, + command: InspectedExecutable, signal?: AbortSignal, ): Promise { let root: string; @@ -356,6 +422,7 @@ async function requireGitRepository( root = await gitOutput( repository, ["rev-parse", "--show-toplevel"], + command, signal, ); } catch (error) { @@ -378,12 +445,14 @@ async function requireGitRepository( async function resolveGitRef( repository: string, ref: string, + command: InspectedExecutable, signal?: AbortSignal, ): Promise { try { return await gitOutput( repository, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + command, signal, ); } catch (error) { @@ -395,30 +464,64 @@ async function resolveGitRef( async function gitOutput( repository: string, args: readonly string[], + command: InspectedExecutable, signal?: AbortSignal, ): Promise { throwIfAborted(signal); - const command = await resolveTrustedExecutable( - "git", - isolatedGitEnvironment(args[0] === "rev-parse"), - await outermostGitMarkerRoot(repository, signal), - ); - if (command === null) + if (command.executable === null) throw new Error("Git is not available on a trusted PATH."); - throwIfAborted(signal); const { stdout } = await execFile( command.executable, ["-c", "core.fsmonitor=false", "-C", repository, ...args], { encoding: "utf8", signal, - env: command.environment, + env: isolatedGitEnvironment(command.environment, args[0] === "rev-parse"), }, ); return stdout.trim(); } -async function outermostGitMarkerRoot( +/** @internal */ +export async function resolveGitCommand( + environment: Readonly>, + protectedRoots: string | readonly string[], +): Promise { + const binding = executableBinding(environment, "CODEX_SECURITY_GIT"); + let inspected = await inspectTrustedExecutable( + "git", + environment, + protectedRoots, + ); + let executable = inspected.executable; + if (binding.value === "") { + executable = null; + } else if (binding.value !== undefined) { + if (!isAbsoluteExecutablePath(binding.value)) { + throw new ConfigurationError( + "CODEX_SECURITY_GIT must name an absolute trusted executable.", + ); + } + inspected = await inspectTrustedExecutable( + binding.value, + inspected.environment, + protectedRoots, + { preserveInvocation: true }, + ); + if (inspected.executable === null) { + throw new ConfigurationError( + "CODEX_SECURITY_GIT does not name an available executable.", + ); + } + executable = inspected.executable; + } + for (const key of binding.keys) delete inspected.environment[key]; + inspected.environment["CODEX_SECURITY_GIT"] = executable ?? ""; + return { executable, environment: inspected.environment }; +} + +/** @internal */ +export async function outermostGitMarkerRoot( repository: string, signal?: AbortSignal, ): Promise { @@ -430,7 +533,7 @@ async function outermostGitMarkerRoot( await lstat(join(current, ".git")); root = current; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (!isUnavailablePathError(error)) throw error; } const parent = dirname(current); if (parent === current) return root; @@ -438,10 +541,95 @@ async function outermostGitMarkerRoot( } } +/** @internal */ +export async function protectedGitInputRoots( + paths: readonly string[], + signal?: AbortSignal, +): Promise { + const roots = new Set(); + const queued = new Set(paths.map(resolveRepositoryPath)); + const pending = [...queued].map((path) => ({ + path, + links: new Set(), + })); + for (let index = 0; index < pending.length; index += 1) { + const { path: requested, links } = pending[index]!; + let current = requested; + while (true) { + throwIfAborted(signal); + const parent = dirname(current); + const metadata = await lstat(current, { bigint: true }).catch( + (error: unknown) => { + if (isUnavailablePathError(error)) return null; + throw error; + }, + ); + if (metadata?.isSymbolicLink()) { + // A relative cycle can produce a new path spelling on every visit. + const identity = `${metadata.dev}:${metadata.ino}`; + if (!links.has(identity)) { + const target = await readlink(current); + // Preserve link-target dot segments until the filesystem resolves them. + const linked = isAbsolute(target) + ? target + : `${parent}${parent.endsWith(sep) ? "" : sep}${target}`; + const suffix = relative(current, requested); + const redirected = suffix + ? `${linked}${linked.endsWith(sep) ? "" : sep}${suffix}` + : linked; + if (!queued.has(redirected)) { + queued.add(redirected); + pending.push({ + path: redirected, + links: new Set([...links, identity]), + }); + } + } + } + if (parent === current) break; + current = parent; + } + let existing = requested; + let canonical = requested; + while (true) { + throwIfAborted(signal); + try { + canonical = join( + await realpath(existing), + relative(existing, requested), + ); + break; + } catch (error) { + if (!isUnavailablePathError(error)) throw error; + const parent = dirname(existing); + if (parent === existing) break; + existing = parent; + } + } + // Both the link's repository and its resolved target can supply input data. + for (const path of new Set([requested, canonical])) { + const root = await outermostGitMarkerRoot(path, signal); + try { + roots.add(await realpath(root)); + } catch (error) { + // A missing standalone input remains the caller's per-input error. + if (!isUnavailablePathError(error)) throw error; + } + } + } + return [...roots]; +} + +function isUnavailablePathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"; +} + function isolatedGitEnvironment( + source: Readonly>, preserveGitConfiguration: boolean, ): NodeJS.ProcessEnv { - const environment = { ...process.env }; + const environment = { ...source }; for (const name of Object.keys(environment)) { const normalized = name.toUpperCase(); if ( diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index aa0cac47f..978f17851 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -2,12 +2,15 @@ import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; import { delimiter, + dirname, extname, isAbsolute, join, + posix, relative, resolve, sep, + win32, } from "node:path"; export interface TrustedExecutable { @@ -15,19 +18,71 @@ export interface TrustedExecutable { environment: Record; } +export interface InspectedExecutable { + executable: string | null; + environment: Record; +} + +export function isAbsoluteExecutablePath(candidate: string): boolean { + if (process.platform !== "win32") return posix.isAbsolute(candidate); + // A Windows root-relative path still depends on the current drive. + return win32.isAbsolute(candidate) && win32.parse(candidate).root.length > 1; +} + +export function executableBinding( + environment: Readonly>, + setting: "CODEX_SECURITY_GIT" | "CODEX_SECURITY_RG", +): { keys: string[]; value: string | undefined } { + const keys = + process.platform === "win32" + ? Object.keys(environment) + .filter((name) => name.toUpperCase() === setting) + .sort() + : [setting]; + return { keys, value: environment[keys[0] ?? setting] }; +} + export async function resolveTrustedExecutable( candidate: string, environment: Readonly>, - protectedRoot: string, + protectedRoots: string | readonly string[], ): Promise { - const root = await realpath(protectedRoot).catch(() => - resolve(protectedRoot), + const inspected = await inspectTrustedExecutable( + candidate, + environment, + protectedRoots, + ); + return inspected.executable === null + ? null + : { executable: inspected.executable, environment: inspected.environment }; +} + +export async function inspectTrustedExecutable( + candidate: string, + environment: Readonly>, + protectedRoots: string | readonly string[], + { preserveInvocation = false }: { preserveInvocation?: boolean } = {}, +): Promise { + const roots = await Promise.all( + (typeof protectedRoots === "string" + ? [protectedRoots] + : protectedRoots + ).map(async (root) => await realpath(root).catch(() => resolve(root))), ); - 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 ?? + (process.platform === "win32" ? process.env["PATH"] : "/usr/bin:/bin") ?? + ""; const entries: string[] = []; - for (let entry of path?.split(delimiter) ?? []) { + for (let entry of searchPath.split(delimiter)) { if ( process.platform === "win32" && entry.startsWith('"') && @@ -37,7 +92,9 @@ export async function resolveTrustedExecutable( } if (entry.length === 0) continue; const canonical = await realpath(entry).catch(() => null); - if (canonical === null || isWithin(root, canonical)) continue; + if (canonical === null || roots.some((root) => isWithin(root, canonical))) { + continue; + } if (!entries.includes(canonical)) entries.push(canonical); } @@ -64,7 +121,9 @@ export async function resolveTrustedExecutable( const candidates = pathLike ? extensions.map((extension) => ({ entry: null, - path: resolve(`${candidate}${extension.suffix}`), + path: preserveInvocation + ? `${candidate}${extension.suffix}` + : resolve(`${candidate}${extension.suffix}`), runnable: extension.runnable, })) : entries.flatMap((entry) => @@ -79,34 +138,73 @@ export async function resolveTrustedExecutable( for (const current of candidates) { const canonical = await realpath(current.path).catch(() => null); if (canonical === null) continue; - if (isWithin(root, canonical)) { + if (roots.some((root) => isWithin(root, canonical))) { if (current.entry !== null) unsafeEntries.add(current.entry); continue; } + if (process.platform === "win32" && /\.(?:bat|cmd)$/iu.test(canonical)) { + continue; + } if (!current.runnable) continue; try { + if ( + preserveInvocation && + (!isAbsoluteExecutablePath(candidate) || + (await hasProtectedAncestor(roots, current.path, canonical))) + ) { + continue; + } await access( canonical, process.platform === "win32" ? constants.F_OK : constants.X_OK, ); if (!(await stat(canonical)).isFile()) continue; - executable ??= pathLike ? canonical : current.path; + executable ??= preserveInvocation + ? candidate + : pathLike + ? canonical + : current.path; } catch { continue; } } - if (executable === null) return null; - 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); return { executable, environment: sanitizedEnvironment }; } +async function hasProtectedAncestor( + roots: readonly string[], + ...paths: string[] +): Promise { + // Identities cover case aliases and junctions in the original invocation. + const protectedIdentities = await Promise.all( + roots.map((root) => stat(root, { bigint: true })), + ); + for (const path of paths) { + // A local Git fetch source can itself be a file. + for (let directory = path; ; ) { + const identity = await stat(directory, { bigint: true }); + if ( + protectedIdentities.some( + (protectedIdentity) => + identity.dev === protectedIdentity.dev && + identity.ino === protectedIdentity.ino, + ) + ) { + return true; + } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + } + return false; +} + function isWithin(root: string, candidate: string): boolean { const path = relative(root, candidate); return ( diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 955feef3f..e9aea86c7 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.21" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.29" as const; const PACKAGE_NAME = "@openai/codex-security"; const VERSION_PATTERN = diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index d198909fa..ec853afb6 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, win32 } 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"; @@ -50,7 +50,11 @@ 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 * as targetsModule from "../src/targets.js"; import { normalizeTarget } from "../src/targets.js"; import { SYNTHETIC_CREDENTIALS } from "./cli-fixtures.js"; import { INTEGRATION_TARGET, PLUGIN_ROOT } from "./plugin-root.js"; @@ -607,6 +611,179 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("rejects selected Git from a knowledge-base repository before preflight", async () => { + if ( + runMockInSubprocess( + import.meta.path, + "rejects selected Git from a knowledge-base repository before preflight", + ) + ) { + return; + } + + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const knowledgeRepository = join(root, "knowledge"); + const document = join(knowledgeRepository, "docs", "guide.md"); + const selected = join( + knowledgeRepository, + process.platform === "win32" ? "selected-git.exe" : "selected-git", + ); + await mkdir(repository); + await mkdir(join(knowledgeRepository, ".git"), { recursive: true }); + await mkdir(join(knowledgeRepository, "docs")); + await writeFile(document, "# Synthetic guidance\n"); + await writeFile(selected, "", { mode: 0o700 }); + const originalTargets = { ...targetsModule }; + let gitCalls = 0; + mock.module("../src/targets.js", () => ({ + ...originalTargets, + enclosingGitWorktreeRoot: async () => { + gitCalls += 1; + return null; + }, + })); + const client = new TestClient( + {}, + { + environment: { + PATH: "", + CODEX_SECURITY_GIT: selected, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }, + }, + ); + try { + await expect( + client.preflight(repository, { knowledgeBasePaths: [document] }), + ).rejects.toThrow( + "CODEX_SECURITY_GIT does not name an available executable.", + ); + expect(gitCalls).toBe(0); + } finally { + await client.close(); + mock.module("../src/targets.js", () => originalTargets); + } + }); + + for (const output of ["explicit", "default"] as const) { + const name = `accepts selected Git from ${output} scan output before preflight`; + test(name, async () => { + if (runMockInSubprocess(import.meta.path, name)) return; + + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const outputDirectory = + output === "explicit" + ? join(root, "scan") + : join(stateDirectory, "scans", "previous"); + const selected = join( + outputDirectory, + process.platform === "win32" ? "selected-git.exe" : "selected-git", + ); + await mkdir(repository); + await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); + await writeFile(selected, "", { mode: 0o700 }); + const originalTargets = { ...targetsModule }; + let selectedExecutable: string | null | undefined; + mock.module("../src/targets.js", () => ({ + ...originalTargets, + enclosingGitWorktreeRoot: async ( + ...args: Parameters + ) => { + selectedExecutable = args[2]?.executable; + return null; + }, + })); + const client = new TestClient( + {}, + { + environment: { + PATH: "", + CODEX_SECURITY_GIT: selected, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }, + ); + try { + await expect( + client.preflight( + repository, + output === "explicit" + ? { outputDir: outputDirectory, archiveExisting: true } + : {}, + ), + ).resolves.toMatchObject({ + repository, + outputDir: output === "explicit" ? outputDirectory : null, + }); + expect(selectedExecutable).toBe(selected); + } finally { + await client.close(); + mock.module("../src/targets.js", () => originalTargets); + } + }); + } + + test("keeps knowledge-base repositories outside runtime tool selection", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const knowledgeRepository = join(root, "knowledge"); + const document = join(knowledgeRepository, "guide.md"); + const selected = join( + knowledgeRepository, + process.platform === "win32" ? "selected-rg.exe" : "selected-rg", + ); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(join(knowledgeRepository, ".git"), { recursive: true }); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(document, "# Synthetic guidance\n"); + await writeFile(selected, "", { mode: 0o700 }); + const environment = { + PATH: "", + CODEX_CLI_PATH: process.execPath, + CODEX_SECURITY_GIT: "", + CODEX_SECURITY_RG: selected, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + OPENAI_API_KEY: "synthetic-key", + }; + let modelStarted = false; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => null, + createCodex: () => { + modelStarted = true; + throw new Error("Model must not start."); + }, + }, + ); + try { + await expect( + client.run(repository, { + outputDir: scanDir, + knowledgeBasePaths: [document], + }), + ).rejects.toThrow( + "CODEX_SECURITY_RG does not name an available executable.", + ); + expect(modelStarted).toBe(false); + } finally { + await client.close(); + } + }); + test("validates knowledge-base documents before initializing the runtime", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -1641,29 +1818,34 @@ describe("CodexSecurity orchestration", () => { const repository = join(worktree, "packages", "service"); const output = join(worktree, "scan"); await mkdir(repository, { recursive: true }); - let runtimeStarted = false; - const client = new TestClient( - {}, - { - environment: {}, - prepareRuntime: async () => { - runtimeStarted = true; - throw new Error("runtime should not initialize"); + for (const environment of [{}, { CODEX_SECURITY_GIT: "" }]) { + let runtimeStarted = false; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => { + runtimeStarted = true; + throw new Error("runtime should not initialize"); + }, }, - }, - ); + ); - await expect( - client.run(repository, { outputDir: output }), - ).rejects.toMatchObject({ - name: OutputInsideProtectedRootError.name, - outputDirectory: output, - protectedRoot: worktree, - pathKind: "output", - }); - expect(runtimeStarted).toBe(false); - await expect(stat(output)).rejects.toThrow(); - await client.close(); + try { + await expect( + client.run(repository, { outputDir: output }), + ).rejects.toMatchObject({ + name: OutputInsideProtectedRootError.name, + outputDirectory: output, + protectedRoot: worktree, + pathKind: "output", + }); + expect(runtimeStarted).toBe(false); + await expect(stat(output)).rejects.toThrow(); + } finally { + await client.close(); + } + } } }); @@ -4572,11 +4754,6 @@ describe("CodexSecurity orchestration", () => { startThread: () => ({ id: null, async runStreamed() { - expect( - existsSync( - join(credentialHome, ".codex-security-scan.lock"), - ), - ).toBe(false); if (++scansStarted === 2) releaseScans(); const credentialConfig = parseToml( await readFile( @@ -4592,6 +4769,11 @@ describe("CodexSecurity orchestration", () => { workers: index + 2, }); await concurrentScans; + expect( + existsSync( + join(credentialHome, ".codex-security-scan.lock"), + ), + ).toBe(false); const after = parseToml( await readFile(deepScanConfigPath!, "utf8"), ); @@ -5465,6 +5647,507 @@ 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 originalTargets = { ...targetsModule }; + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const inspected: [string, string | readonly string[]][] = []; + const gitSelections: (string | null | undefined)[] = []; + 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, + inspectTrustedExecutable: async ( + candidate: string, + environment: Record, + protectedRoot: string | readonly 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" + ? gitHost + : candidate === rejected + ? null + : candidate === "rg" + ? host + : candidate, + environment: sanitizedEnvironment, + }; + }, + })); + mock.module("../src/runtime.js", () => ({ + ...originalRuntime, + pluginExecutionEnvironment: ( + python: string, + environment: Record, + ) => ({ + ...environment, + PYTHON: python, + CODEX_CLI_PATH: process.execPath, + }), + })); + mock.module("../src/targets.js", () => ({ + ...originalTargets, + enclosingGitWorktreeRoot: async ( + _repository: string, + _signal?: AbortSignal, + command?: trustedExecutable.InspectedExecutable, + ) => { + gitSelections.push(command?.executable); + return null; + }, + })); + const cases: { + scenario: string; + platform?: NodeJS.Platform; + host: boolean; + gitAvailable?: boolean; + configuredTool?: "git" | "rg"; + bindings?: Record; + expected: "host" | "staged" | "disabled" | "missing"; + expectedGit?: "host" | "disabled"; + }[] = [ + { 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: "overlapping-knowledge-base", + 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, + configuredTool: "rg", + bindings: { 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, + configuredTool: "git", + 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, + configuredTool: "git", + bindings: { 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 { + 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 knowledgeBaseDirectory = join(root, "knowledge-base"); + const knowledgeBasePaths = + scenario === "overlapping-knowledge-base" + ? [join(knowledgeBaseDirectory, "guide.md")] + : undefined; + if (knowledgeBasePaths !== undefined) { + await mkdir(join(knowledgeBaseDirectory, ".git"), { + recursive: true, + mode: 0o700, + }); + await writeFile(knowledgeBasePaths[0]!, "# Synthetic guidance\n"); + } + const scanOptions = + knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }; + const workspace = + scenario === "overlapping-workspace" + ? repository + : scenario === "overlapping-knowledge-base" + ? join(knowledgeBaseDirectory, "bootstrap-workspace") + : 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 hostTool = (name: string): string => + process.platform === "win32" + ? win32.join("C:\\host-tools", name) + : join(root, "host-tools", name); + gitHost = entry.gitAvailable + ? hostTool(process.platform === "win32" ? "git.exe" : "git") + : null; + const staged = + scenario === "rejected-copy" + ? join(repository, filename) + : join(workspace, filename); + host = entry.host ? hostTool(filename) : null; + rejected = scenario === "rejected-copy" ? staged : null; + inspected.length = 0; + gitSelections.length = 0; + sanitizedGitEnvironment = undefined; + 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, + ...(entry.configuredTool === "git" + ? { CODEX_SECURITY_GIT: gitHost! } + : {}), + ...(entry.configuredTool === "rg" + ? { CODEX_SECURITY_RG: host! } + : {}), + }; + 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 { + await client.preflight(repository, scanOptions); + expect(gitSelections).toEqual([ + entry.expectedGit === "host" ? gitHost : null, + ]); + if ( + scenario === "overlapping-workspace" || + scenario === "overlapping-knowledge-base" + ) { + const failure = client.run(repository, scanOptions); + await expect(failure).rejects.toBeInstanceOf( + OutputInsideProtectedRootError, + ); + await expect(failure).rejects.toMatchObject({ + protectedRoot: + scenario === "overlapping-workspace" + ? repository + : knowledgeBaseDirectory, + pathKind: "runtime", + }); + expect(stageCalls).toEqual([]); + expect(codexEnvironments).toEqual([]); + continue; + } + await expect(client.run(repository, scanOptions)).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_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 + : 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); + mock.module("../src/targets.js", () => originalTargets); + } + }); + + test("refreshes explicit tool settings when a client reuses its runtime", async () => { + const name = + "refreshes explicit tool settings when a client reuses its runtime"; + if (runMockInSubprocess(import.meta.path, name)) return; + + const originalTrusted = { ...trustedExecutable }; + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const stateDirectory = join(root, "state"); + const scanDir = join(root, "scan"); + for (const path of [repository, codexHome, scanDir]) { + await mkdir(path, { mode: 0o700 }); + } + const filename = process.platform === "win32" ? "rg.exe" : "rg"; + const firstPath = join(root, "first-tools"); + const nextPath = join(root, "next-tools"); + const explicit = join(root, "selected-tools", filename); + const environment: Record = { + PATH: firstPath, + CODEX_CLI_PATH: process.execPath, + CODEX_HOME: join(root, "ambient-home"), + CODEX_SECURITY_STATE_DIR: stateDirectory, + CODEX_SECURITY_RG: explicit, + OPENAI_API_KEY: "synthetic-key", + }; + const runtimeEnvironment: Record = { + ...environment, + CODEX_HOME: codexHome, + }; + const runtime = { + ...preparedRuntime(codexHome), + environment: runtimeEnvironment, + }; + const workbenchEnvironments: WorkbenchCommandOptions["environment"][] = []; + const codexEnvironments: CodexOptions["env"][] = []; + let runtimePreparations = 0; + mock.module("../src/trusted-executable.js", () => ({ + ...originalTrusted, + inspectTrustedExecutable: async ( + candidate: string, + current: Record, + _protectedRoot: string | readonly string[], + options?: { preserveInvocation?: boolean }, + ) => { + if (candidate !== "git" && candidate !== "rg") { + expect(options?.preserveInvocation).toBe(true); + } + return { + executable: + candidate === "git" + ? null + : candidate === "rg" + ? join(current["PATH"]!, filename) + : candidate, + environment: { ...current }, + }; + }, + })); + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => { + runtimePreparations++; + return runtime; + }, + resolvePluginPython: async () => join(root, "managed-python"), + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => null, + stageBundledRipgrep: async () => { + throw new Error("A selected host tool must not be restaged"); + }, + 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 fresh tool environment"); + }, + }, + ); + const run = async (selection: string, path: string) => { + await expect(client.run(repository)).rejects.toThrow( + "captured fresh tool environment", + ); + for (const selected of [ + workbenchEnvironments.at(-1), + codexEnvironments.at(-1), + ]) { + expect(selected).toMatchObject({ + CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + CODEX_SECURITY_GIT: "", + CODEX_SECURITY_RG: selection, + PATH: path, + }); + } + }; + try { + await run(explicit, firstPath); + environment["CODEX_SECURITY_RG"] = ""; + environment["PATH"] = nextPath; + await run("", nextPath); + delete environment["CODEX_SECURITY_RG"]; + await run(join(nextPath, filename), nextPath); + environment["CODEX_SECURITY_RG"] = explicit; + await run(explicit, nextPath); + expect(runtimePreparations).toBe(1); + expect(runtime.environment["CODEX_SECURITY_RG"]).toBe(explicit); + expect(workbenchEnvironments).toHaveLength(4); + expect(codexEnvironments).toHaveLength(4); + } finally { + await client.close(); + mock.module("../src/trusted-executable.js", () => originalTrusted); + } + }); + test("authenticates without initializing the plugin runtime", async () => { const root = await temporaryDirectory(); const stateDirectory = join(root, "state"); diff --git a/sdk/typescript/tests-ts/cli-export.test.ts b/sdk/typescript/tests-ts/cli-export.test.ts index 923e247c0..f81b7d1aa 100644 --- a/sdk/typescript/tests-ts/cli-export.test.ts +++ b/sdk/typescript/tests-ts/cli-export.test.ts @@ -40,6 +40,7 @@ describe("CLI", () => { Path: "C:\\Python;C:\\Windows\\System32", PYTHON: "/managed/python", TMPDIR: "/tmp", + CODEX_SECURITY_GIT: "", OPENAI_API_KEY: "openai-secret", CODEX_API_KEY: "codex-secret", GITHUB_TOKEN: "github-secret", @@ -49,7 +50,11 @@ describe("CLI", () => { Path: "C:\\Python;C:\\Windows\\System32", PYTHON: "/managed/python", TMPDIR: "/tmp", + CODEX_SECURITY_GIT: "", }); + expect(exportEnvironment({ CODEX_SECURITY_GIT: "/synthetic/git" })).toEqual( + { CODEX_SECURITY_GIT: "/synthetic/git" }, + ); }); test("exports findings to stdout without initializing Codex", async () => { diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 33676192a..522e15bb6 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { access, appendFile, @@ -18,13 +18,15 @@ import { import * as filesystem from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { main } from "../src/cli.js"; import { ScanCostLimitExceededError } from "../src/errors.js"; import type { ScanResult } from "../src/result.js"; import { buildGitHubCredentialArgs, runMultiscan } from "../src/multiscan.js"; +import * as targetsModule from "../src/targets.js"; import { resolveTrustedExecutable } from "../src/trusted-executable.js"; import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; +import { runMockInSubprocess } from "./support/isolated-mock.js"; type MultiscanOptions = Parameters[0]; type SecurityClient = ReturnType; @@ -133,6 +135,367 @@ async function results(path: string): Promise[]> { } describe("multiscan", () => { + for (const location of [ + "inventory file", + "knowledge base", + "linked missing source", + "dangling linked source", + "campaign artifacts", + "launch directory", + ] as const) { + const acceptsSelected = + location === "campaign artifacts" || location === "launch directory"; + const name = + location === "campaign artifacts" + ? "accepts selected Git from existing campaign artifacts" + : location === "launch directory" + ? "accepts selected Git from an unrelated launch directory" + : `rejects selected Git from the ${location} repository`; + test(name, async () => { + if (location === "launch directory") { + const launchDirectory = process.env["CODEX_SECURITY_TEST_LAUNCH_CWD"]; + if (launchDirectory === undefined) { + const launchFixture = await fixture(); + const launch = await repository(launchFixture.root, "launch"); + const execution = spawnSync( + process.execPath, + [ + "--no-env-file", + "test", + "--timeout", + "30000", + "--test-name-pattern", + name, + import.meta.path, + ], + { + cwd: launch.path, + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_ISOLATED_MOCK: "1", + CODEX_SECURITY_TEST_LAUNCH_CWD: launch.path, + }, + windowsHide: true, + }, + ); + expect(execution.status, execution.stderr).toBe(0); + return; + } + expect(await realpath(process.cwd())).toBe(launchDirectory); + } + if (runMockInSubprocess(import.meta.path, name)) return; + + const paths = await fixture(); + const source = await repository(paths.root, "source"); + const inputRepository = await repository(paths.root, "inputs"); + const document = join(inputRepository.path, "guide.md"); + const selectedDirectory = + location === "campaign artifacts" + ? join(paths.output, "artifacts", "prior", "attempt-1") + : location === "launch directory" + ? join(process.cwd(), "tools") + : inputRepository.path; + await mkdir(selectedDirectory, { recursive: true, mode: 0o700 }); + const selected = join( + selectedDirectory, + process.platform === "win32" ? "selected-git.exe" : "selected-git", + ); + await writeFile(document, "# Synthetic guidance\n"); + await writeFile(selected, "", { mode: 0o700 }); + const input = + location === "inventory file" + ? join(inputRepository.path, "repositories.csv") + : paths.input; + let inventory = `id,repository,revision\nsource,${source.path},${source.revision}\n`; + const missingSource = + location === "linked missing source" || + location === "dangling linked source"; + if (missingSource) { + const link = join(paths.root, "input-link"); + const target = + location === "dangling linked source" + ? join(inputRepository.path, "removed-directory") + : join(inputRepository.path, "src"); + if (location === "dangling linked source") await mkdir(target); + await symlink( + target, + link, + process.platform === "win32" ? "junction" : "dir", + ); + if (location === "dangling linked source") { + await rm(target, { recursive: true }); + } + inventory += `missing,${join(link, "missing")},${source.revision}\n`; + } + await writeFile(input, inventory); + + const originalTargets = { ...targetsModule }; + const previousGit = process.env["CODEX_SECURITY_GIT"]; + let selectedAccepted = false; + let scans = 0; + mock.module("../src/targets.js", () => ({ + ...originalTargets, + resolveGitCommand: async ( + ...args: Parameters + ) => { + const command = await originalTargets.resolveGitCommand(...args); + if (command.executable === selected) { + selectedAccepted = true; + throw new Error( + "Inert selected Git reached the execution boundary.", + ); + } + return command; + }, + })); + try { + process.env["CODEX_SECURITY_GIT"] = selected; + const summary = await runMultiscan( + options( + { ...paths, input }, + client(async (_checkout, scanOptions = {}) => { + scans += 1; + return await completedScan(scanOptions.outputDir!); + }), + { + maxAttempts: 1, + ...(location === "knowledge base" || + location === "launch directory" + ? { knowledgeBasePaths: [document] } + : {}), + }, + ), + ); + expect(summary).toMatchObject({ + completed: 0, + failed: missingSource ? 2 : 1, + }); + expect(selectedAccepted).toBe(acceptsSelected); + expect(scans).toBe(0); + for (const receipt of await results(summary.resultsPath)) { + expect(receipt["error"]).toContain( + acceptsSelected + ? "Inert selected Git reached the execution boundary." + : "CODEX_SECURITY_GIT does not name an available executable.", + ); + } + } finally { + if (previousGit === undefined) delete process.env["CODEX_SECURITY_GIT"]; + else process.env["CODEX_SECURITY_GIT"] = previousGit; + mock.module("../src/targets.js", () => originalTargets); + } + }); + } + + for (const location of [ + "pending inventory", + "completed inventory", + "managed checkout", + ] as const) { + const name = `rejects selected Git from ${location} campaign roots`; + test(name, async () => { + if (runMockInSubprocess(import.meta.path, name)) return; + + const paths = await fixture(); + const outer = await repository(paths.root, "outer"); + const source = await repository(outer.path, "source"); + const other = await repository(paths.root, "other"); + const selectedDirectory = + location === "managed checkout" + ? join(paths.output, "checkouts", "retained") + : outer.path; + await mkdir(selectedDirectory, { recursive: true, mode: 0o700 }); + const selected = join( + selectedDirectory, + process.platform === "win32" ? "selected-git.exe" : "selected-git", + ); + await writeFile(selected, "", { mode: 0o700 }); + await writeFile( + paths.input, + `id,repository,revision\nother,${other.path},${other.revision}\nsource,${source.path},${source.revision}\n`, + ); + const previousGit = process.env["CODEX_SECURITY_GIT"]; + let previousReceipts = 0; + let scans = 0; + try { + if (location === "completed inventory") { + delete process.env["CODEX_SECURITY_GIT"]; + const initial = await runMultiscan( + options( + paths, + client(async (_checkout, scanOptions = {}) => + completedScan(scanOptions.outputDir!), + ), + { maxAttempts: 1 }, + ), + ); + expect(initial).toMatchObject({ completed: 2, failed: 0 }); + const receipts = await results(initial.resultsPath); + previousReceipts = receipts.length; + const otherReceipt = receipts.find( + (receipt) => receipt["id"] === "other", + )!; + await rm(join(otherReceipt["outputDir"] as string, "report.md")); + } + + process.env["CODEX_SECURITY_GIT"] = selected; + const summary = await runMultiscan( + options( + paths, + client(async (_checkout, scanOptions = {}) => { + scans += 1; + return await completedScan(scanOptions.outputDir!); + }), + { maxAttempts: 1 }, + ), + ); + const resumed = location === "completed inventory"; + expect(summary).toMatchObject({ + completed: resumed ? 1 : 0, + failed: resumed ? 1 : 2, + skipped: resumed ? 1 : 0, + }); + expect(scans).toBe(0); + const receipts = (await results(summary.resultsPath)).slice( + previousReceipts, + ); + expect(receipts).toHaveLength(resumed ? 1 : 2); + for (const receipt of receipts) { + expect(receipt["error"]).toContain( + "CODEX_SECURITY_GIT does not name an available executable.", + ); + } + } finally { + if (previousGit === undefined) delete process.env["CODEX_SECURITY_GIT"]; + else process.env["CODEX_SECURITY_GIT"] = previousGit; + } + }); + } + + for (const kind of ["missing", "cyclic"] as const) { + const name = `keeps ${kind} local sources as per-task failures`; + test(name, async () => { + if (runMockInSubprocess(import.meta.path, name)) return; + + const paths = await fixture(); + const source = await repository(paths.root, "source"); + let unavailable = join(paths.root, "missing"); + if (kind === "cyclic") { + const target = join(paths.root, "cycle-target"); + const link = join(paths.root, "cycle-link"); + const type = process.platform === "win32" ? "junction" : "dir"; + await mkdir(target); + await symlink(target, link, type); + await rm(target, { recursive: true }); + await symlink(link, target, type); + unavailable = join(link, "missing"); + } + const trusted = await resolveTrustedExecutable( + "git", + process.env, + process.cwd(), + ); + if (trusted === null) throw new Error("Git is required by this fixture."); + await writeFile( + paths.input, + `id,repository,revision\n${kind},${unavailable},${source.revision}\nsource,${source.path},${source.revision}\n`, + ); + const previousPath = process.env["PATH"]; + const previousGit = process.env["CODEX_SECURITY_GIT"]; + let scans = 0; + try { + process.env["PATH"] = ""; + process.env["CODEX_SECURITY_GIT"] = trusted.executable; + const summary = await runMultiscan( + options( + paths, + client(async (_checkout, scanOptions = {}) => { + scans += 1; + return await completedScan(scanOptions.outputDir!); + }), + { maxAttempts: 1 }, + ), + ); + expect(summary).toMatchObject({ completed: 1, failed: 1 }); + expect(scans).toBe(1); + expect(await results(summary.resultsPath)).toMatchObject([ + { id: kind, status: "failed" }, + { id: "source", status: "completed" }, + ]); + } finally { + if (previousPath === undefined) delete process.env["PATH"]; + else process.env["PATH"] = previousPath; + if (previousGit === undefined) delete process.env["CODEX_SECURITY_GIT"]; + else process.env["CODEX_SECURITY_GIT"] = previousGit; + } + }); + } + + for (const setting of ["selected", "disabled", "invalid"] as const) { + const name = `honors ${setting} Git settings before bulk checkout`; + test(name, async () => { + if (runMockInSubprocess(import.meta.path, name)) return; + + const paths = await fixture(); + const source = await repository(paths.root, "source"); + const trusted = await resolveTrustedExecutable( + "git", + process.env, + process.cwd(), + ); + if (trusted === null) throw new Error("Git is required by this fixture."); + await writeFile( + paths.input, + `id,repository,revision\nsource,${source.path},${source.revision}\n`, + ); + const previousPath = process.env["PATH"]; + const previousGit = process.env["CODEX_SECURITY_GIT"]; + let scans = 0; + try { + process.env["CODEX_SECURITY_GIT"] = + setting === "selected" + ? trusted.executable + : setting === "disabled" + ? "" + : join(paths.root, "missing-git"); + if (setting === "selected") process.env["PATH"] = ""; + + const summary = await runMultiscan( + options( + paths, + client(async (checkout, scanOptions = {}) => { + scans += 1; + expect( + await readFile(join(checkout, "src", "app.ts"), "utf8"), + ).toContain('export const name = "source";'); + return await completedScan(scanOptions.outputDir!); + }), + { maxAttempts: 1 }, + ), + ); + + if (setting === "selected") { + expect(summary).toMatchObject({ completed: 1, failed: 0 }); + expect(scans).toBe(1); + } else { + expect(summary).toMatchObject({ completed: 0, failed: 1 }); + expect(scans).toBe(0); + expect((await results(summary.resultsPath))[0]?.["error"]).toContain( + setting === "disabled" + ? "Git is not available on a trusted PATH." + : "CODEX_SECURITY_GIT does not name an available executable.", + ); + } + } finally { + if (previousPath === undefined) delete process.env["PATH"]; + else process.env["PATH"] = previousPath; + if (previousGit === undefined) delete process.env["CODEX_SECURITY_GIT"]; + else process.env["CODEX_SECURITY_GIT"] = previousGit; + } + }); + } + test("scopes GitHub CLI credentials to the discovered GitHub host", () => { expect(buildGitHubCredentialArgs(undefined)).toEqual([]); expect(buildGitHubCredentialArgs("github.com")).toEqual([ @@ -1546,7 +1909,7 @@ describe("multiscan", () => { paths.input, `id,repository,revision\nprivate,${source.path},${source.revision}\n`, ); - const shimDirectory = join(paths.root, "node_modules", ".bin"); + const shimDirectory = join(source.path, "node_modules", ".bin"); const leakedCredential = join(paths.root, "leaked-credential"); await mkdir(shimDirectory, { recursive: true }); await writeFile( @@ -1554,7 +1917,6 @@ describe("multiscan", () => { `#!/bin/sh\nprintf '%s' "$GIT_CONFIG_VALUE_0" > "${leakedCredential}"\nexit 1\n`, { mode: 0o700 }, ); - const previousDirectory = process.cwd(); const environment = new Map( [ "PATH", @@ -1565,7 +1927,6 @@ describe("multiscan", () => { ); try { - process.chdir(paths.root); process.env["PATH"] = `${shimDirectory}${process.platform === "win32" ? ";" : ":"}${environment.get("PATH") ?? ""}`; process.env["GIT_CONFIG_COUNT"] = "1"; @@ -1602,7 +1963,6 @@ describe("multiscan", () => { expect(summary).toMatchObject({ completed: 1, failed: 0 }); await expect(access(leakedCredential)).rejects.toThrow(); } finally { - process.chdir(previousDirectory); for (const [name, value] of environment) { if (value === undefined) delete process.env[name]; else process.env[name] = value; diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 20e2035bb..fdc32e852 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); diff --git a/sdk/typescript/tests-ts/targets-git-binding.test.ts b/sdk/typescript/tests-ts/targets-git-binding.test.ts new file mode 100644 index 000000000..060229ec8 --- /dev/null +++ b/sdk/typescript/tests-ts/targets-git-binding.test.ts @@ -0,0 +1,205 @@ +import * as childProcess from "node:child_process"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, mock, test } from "bun:test"; +import { ConfigurationError } from "../src/errors.js"; +import * as trustedExecutable from "../src/trusted-executable.js"; +import { runMockInSubprocess } from "./support/isolated-mock.js"; + +test("preserves raw executable bindings across platform aliases", () => { + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + "platform", + )!; + const environment = { + CODEX_SECURITY_GIT: "", + Codex_Security_Git: "/synthetic/git", + }; + try { + Object.defineProperty(process, "platform", { value: "linux" }); + expect( + trustedExecutable.executableBinding(environment, "CODEX_SECURITY_GIT"), + ).toEqual({ keys: ["CODEX_SECURITY_GIT"], value: "" }); + expect( + trustedExecutable.executableBinding( + { Codex_Security_Git: "/synthetic/git" }, + "CODEX_SECURITY_GIT", + ).value, + ).toBeUndefined(); + Object.defineProperty(process, "platform", { value: "win32" }); + expect( + trustedExecutable.executableBinding(environment, "CODEX_SECURITY_GIT"), + ).toEqual({ + keys: ["CODEX_SECURITY_GIT", "Codex_Security_Git"], + value: "", + }); + expect( + trustedExecutable.executableBinding( + { Codex_Security_Git: "/synthetic/git" }, + "CODEX_SECURITY_GIT", + ).value, + ).toBe("/synthetic/git"); + } finally { + Object.defineProperty(process, "platform", originalPlatform); + } +}); + +test("uses one explicit Git command throughout target validation", async () => { + const name = "uses one explicit Git command throughout target validation"; + if (runMockInSubprocess(import.meta.path, name)) return; + + const root = await realpath(await mkdtemp(join(tmpdir(), "git-binding-"))); + const repository = join(root, "repository"); + await mkdir(repository); + const selected = join(root, "tools", "selected git"); + const discovered = join(root, "tools", "path git"); + const canonical = join(root, "tools", "canonical git"); + const missing = join(root, "tools", "missing git"); + const revision = "a".repeat(40); + const originalChildProcess = { ...childProcess }; + const originalTrusted = { ...trustedExecutable }; + const originalBinding = process.env["CODEX_SECURITY_GIT"]; + const inspections: string[] = []; + const calls: { + executable: string; + args: readonly string[]; + environment: NodeJS.ProcessEnv; + }[] = []; + const execute = Object.assign( + () => { + throw new Error("The callback form is not used by this test"); + }, + { + [promisify.custom]: async ( + executable: string, + args: readonly string[], + options: { env: NodeJS.ProcessEnv }, + ) => { + calls.push({ executable, args, environment: options.env }); + const stdout = args.includes("--show-toplevel") + ? repository + : args.includes("--verify") + ? revision + : args.includes("ls-files") + ? "H file.txt\0" + : ""; + return { stdout, stderr: "" }; + }, + }, + ); + mock.module("node:child_process", () => ({ + ...originalChildProcess, + execFile: execute, + })); + mock.module("../src/trusted-executable.js", () => ({ + ...originalTrusted, + inspectTrustedExecutable: async ( + candidate: string, + environment: Record, + protectedRoot: string, + options?: { preserveInvocation?: boolean }, + ) => { + expect(protectedRoot).toBe(repository); + inspections.push(candidate); + return { + executable: + candidate === "git" + ? discovered + : candidate === selected + ? options?.preserveInvocation + ? selected + : canonical + : null, + environment: { ...environment, PATH: "/synthetic/safe-path" }, + }; + }, + })); + + try { + const targets = await import("../src/targets.js"); + const command = await targets.resolveGitCommand( + { + PATH: "", + CODEX_SECURITY_GIT: selected, + GIT_DIR: "/synthetic/ignored-directory", + GIT_CONFIG_COUNT: "1", + KEEP: "present", + }, + repository, + ); + expect(command.executable).toBe(selected); + expect(inspections).toEqual(["git", selected]); + const target = await targets.normalizeTarget( + repository, + targets.DiffTarget.refs({ base: "HEAD" }), + undefined, + command, + ); + await targets.validateCommittedDiffCheckout( + repository, + target, + undefined, + command, + ); + expect( + await targets.enclosingGitWorktreeRoot(repository, undefined, command), + ).toBe(repository); + expect( + await targets.repositoryRevision(repository, undefined, command), + ).toBe(revision); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.executable).toBe(selected); + expect(call.environment["CODEX_SECURITY_GIT"]).toBe(selected); + expect(call.environment["PATH"]).toBe("/synthetic/safe-path"); + expect(call.environment["KEEP"]).toBe("present"); + expect(call.environment["GIT_DIR"]).toBeUndefined(); + expect(call.environment["GIT_ALLOW_PROTOCOL"]).toBe(""); + expect(call.environment["GIT_CONFIG_COUNT"]).toBe( + call.args.includes("rev-parse") ? "1" : undefined, + ); + } + expect(inspections).toEqual(["git", selected]); + + const disabled = await targets.resolveGitCommand( + { CODEX_SECURITY_GIT: "" }, + repository, + ); + const previousCalls = calls.length; + expect( + await targets.repositoryRevision(repository, undefined, disabled), + ).toBeNull(); + expect( + await targets.enclosingGitWorktreeRoot(repository, undefined, disabled), + ).toBeNull(); + await expect( + targets.normalizeTarget( + repository, + targets.DiffTarget.refs({ base: "HEAD" }), + undefined, + disabled, + ), + ).rejects.toThrow("Diff targets require a Git repository"); + expect(calls).toHaveLength(previousCalls); + + await expect( + targets.resolveGitCommand({ CODEX_SECURITY_GIT: "relative" }, repository), + ).rejects.toBeInstanceOf(ConfigurationError); + await expect( + targets.resolveGitCommand({ CODEX_SECURITY_GIT: missing }, repository), + ).rejects.toBeInstanceOf(ConfigurationError); + expect(calls).toHaveLength(previousCalls); + + process.env["CODEX_SECURITY_GIT"] = selected; + expect(await targets.repositoryRevision(repository)).toBe(revision); + expect(calls.at(-1)?.executable).toBe(selected); + } finally { + if (originalBinding === undefined) delete process.env["CODEX_SECURITY_GIT"]; + else process.env["CODEX_SECURITY_GIT"] = originalBinding; + mock.module("node:child_process", () => originalChildProcess); + mock.module("../src/trusted-executable.js", () => originalTrusted); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/sdk/typescript/tests-ts/targets.test.ts b/sdk/typescript/tests-ts/targets.test.ts index f24f2aa7b..c5f2c80e8 100644 --- a/sdk/typescript/tests-ts/targets.test.ts +++ b/sdk/typescript/tests-ts/targets.test.ts @@ -12,7 +12,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "bun:test"; import { @@ -23,6 +23,10 @@ import { repositoryRevision, type ScanTarget, } from "../src/index.js"; +import { + outermostGitMarkerRoot, + protectedGitInputRoots, +} from "../src/targets.js"; // @ts-expect-error DiffTarget is intentionally nominal; use its constructor helpers. const structurallyInvalidTarget: ScanTarget = { @@ -62,6 +66,185 @@ function git(repo: string, ...args: string[]): string { return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); } +test("finds outer Git roots without rejecting local source files", async () => { + const repo = await repository(); + const nested = join(repo, "nested"); + await mkdir(join(nested, ".git"), { recursive: true }); + const inside = join(nested, "source.bundle"); + const outside = join(dirname(repo), "standalone.bundle"); + await Promise.all([ + writeFile(inside, "synthetic bundle fixture\n"), + writeFile(outside, "synthetic bundle fixture\n"), + ]); + + expect(await outermostGitMarkerRoot(inside)).toBe(repo); + expect(await outermostGitMarkerRoot(outside)).toBe(outside); +}); + +test("protects lexical and resolved input repositories without claiming missing standalone paths", async () => { + const lexical = await repository(); + const resolved = await repository(); + const link = join(lexical, "linked-input"); + const standalone = join(dirname(lexical), "standalone.bundle"); + await symlink( + join(resolved, "src"), + link, + process.platform === "win32" ? "junction" : "dir", + ); + await writeFile(standalone, "synthetic bundle fixture\n"); + + expect(await protectedGitInputRoots([join(link, "missing")])).toEqual([ + lexical, + resolved, + ]); + expect( + await protectedGitInputRoots([join(dirname(lexical), "missing")]), + ).toEqual([]); + expect(await protectedGitInputRoots([standalone, standalone])).toEqual([ + standalone, + ]); + + const outside = join(dirname(resolved), "outside"); + const outsideFile = join(outside, "source.bundle"); + const outsideLink = join(dirname(lexical), "outside-link"); + await mkdir(outside); + await writeFile(outsideFile, "synthetic bundle fixture\n"); + await symlink( + outside, + outsideLink, + process.platform === "win32" ? "junction" : "dir", + ); + expect( + await protectedGitInputRoots([join(outsideLink, "source.bundle")]), + ).toEqual([outsideFile]); +}); + +test("keeps unavailable filesystem roots out of shared input roots", async () => { + const repo = await repository(); + const script = ` + import { mock } from "bun:test"; + import * as original from "node:fs/promises"; + import { dirname, join } from "node:path"; + const [repo, targets] = process.argv.slice(1); + const missing = join(dirname(repo), "unavailable-root", "repo"); + const unavailable = new Set(); + for (let path = missing; ; path = dirname(path)) { + unavailable.add(path); + if (dirname(path) === path) break; + } + const originalPromises = { ...original }; + let errorCode = "ENOENT"; + mock.module("node:fs/promises", () => ({ + ...originalPromises, + realpath: async (path, ...args) => { + if (unavailable.has(path)) { + throw Object.assign(new Error("synthetic unavailable root"), { + code: errorCode, + }); + } + return await originalPromises.realpath(path, ...args); + }, + })); + try { + const { protectedGitInputRoots } = await import(targets); + const roots = await protectedGitInputRoots([missing, repo]); + errorCode = "EACCES"; + let unexpectedError = null; + try { + await protectedGitInputRoots([missing]); + } catch (error) { + unexpectedError = error.code; + } + console.log(JSON.stringify({ roots, unexpectedError })); + } finally { + mock.module("node:fs/promises", () => originalPromises); + } + `; + const result = spawnSync( + process.execPath, + [ + "-e", + script, + repo, + fileURLToPath(new URL("../src/targets.ts", import.meta.url)), + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + roots: [repo], + unexpectedError: "EACCES", + }); +}); + +test("finds input repositories behind dangling directory links", async () => { + const lexical = await repository(); + const resolved = await repository(); + const target = join(resolved, "removed-directory"); + const link = join(lexical, "linked-input"); + await mkdir(target); + await symlink( + target, + link, + process.platform === "win32" ? "junction" : "dir", + ); + await rm(target, { recursive: true }); + + expect(await protectedGitInputRoots([join(link, "missing")])).toEqual([ + lexical, + resolved, + ]); +}); + +test("protects both repositories in a cyclic input link", async () => { + const lexical = await repository(); + const resolved = await repository(); + const left = join(lexical, "loop"); + const right = join(resolved, "loop"); + const type = process.platform === "win32" ? "junction" : "dir"; + await mkdir(right); + await symlink(right, left, type); + await rm(right, { recursive: true }); + await symlink(left, right, type); + + expect(await protectedGitInputRoots([join(left, "missing")])).toEqual([ + lexical, + resolved, + ]); +}); + +(process.platform === "win32" ? test.skip : test)( + "does not revisit a relative cyclic input link", + async () => { + const repo = await repository(); + const link = join(repo, "loop"); + await symlink("./loop/nested", link, "dir"); + + expect(await protectedGitInputRoots([join(link, "missing")])).toEqual([ + repo, + ]); + }, +); + +(process.platform === "win32" ? test.skip : test)( + "preserves relative dangling-link target traversal", + async () => { + const lexical = await repository(); + const resolved = await repository(); + const target = join(resolved, "removed-directory"); + const link = join(lexical, "linked-input"); + await mkdir(target); + await symlink(join(resolved, "src"), join(lexical, "bridge"), "dir"); + await symlink("bridge/../removed-directory", link, "dir"); + await rm(target, { recursive: true }); + + expect(await protectedGitInputRoots([join(link, "missing")])).toEqual([ + lexical, + resolved, + ]); + }, +); + async function createRepositoryGitShim( directory: string, marker: string, diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index 8c74d8e5a..335c2a948 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; +import { constants } from "node:fs"; import { chmod, + link, mkdir, mkdtemp, realpath, @@ -8,11 +10,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 { basename, delimiter, dirname, join, relative, sep } 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[] = []; @@ -77,6 +84,129 @@ async function resolveWindowsExecutable( } describe("trusted executable resolution", () => { + test("requires fully qualified Windows paths for explicit tool bindings", () => { + const cases: [string, string | null][] = [ + [String.raw`\tools\git.exe`, null], + ["/tools/git.exe", null], + [String.raw`C:tools\git.exe`, null], + [String.raw`\\server`, null], + [String.raw`C:\tools\git.exe`, String.raw`C:\tools\git.exe`], + ["C:/tools/git.exe", "C:/tools/git.exe"], + [String.raw`\\server\share\git.exe`, String.raw`\\server\share\git.exe`], + ["//server/share/git.exe", "//server/share/git.exe"], + [String.raw`\\?\C:\tools\git.exe`, String.raw`\\?\C:\tools\git.exe`], + ]; + const script = ` + import { mock } from "bun:test"; + import * as originalPromises from "node:fs/promises"; + import * as originalPath from "node:path"; + const [modulePath, values, repository] = process.argv.slice(1); + const windows = originalPath.win32; + const canonical = (path) => windows.resolve(windows.parse(repository).root, path); + const identities = new Map(); + const identity = (path) => { + const key = canonical(path).toLowerCase(); + if (!identities.has(key)) identities.set(key, BigInt(identities.size + 1)); + return identities.get(key); + }; + Object.defineProperty(process, "platform", { value: "win32" }); + mock.module("node:path", () => ({ ...originalPath, ...windows })); + mock.module("node:fs/promises", () => ({ + ...originalPromises, + realpath: async (path) => canonical(path), + access: async () => {}, + stat: async (path) => ({ dev: 1n, ino: identity(path), isFile: () => true }), + })); + const { inspectTrustedExecutable } = await import(modulePath); + const results = []; + for (const value of JSON.parse(values)) { + const result = await inspectTrustedExecutable(value, { PATH: "" }, repository, { + preserveInvocation: true, + }); + results.push(result.executable); + } + console.log(JSON.stringify(results)); + `; + const result = spawnSync( + process.execPath, + [ + "-e", + script, + fileURLToPath(new URL("../src/trusted-executable.ts", import.meta.url)), + JSON.stringify(cases.map(([value]) => value)), + String.raw`C:\repository`, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual( + cases.map(([, executable]) => executable), + ); + }); + + test("excludes every protected root before selecting an executable", async () => { + const root = await temporaryDirectory(); + const repositories = [join(root, "working"), join(root, "source")]; + const unsafe = repositories.map((repository) => join(repository, "bin")); + const trusted = join(root, "trusted"); + const name = process.platform === "win32" ? "git.exe" : "git"; + for (const directory of [...unsafe, trusted]) { + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, name), "synthetic executable\n", { + mode: 0o700, + }); + } + const linked = join(repositories[1]!, "host-tools"); + await symlink( + trusted, + linked, + process.platform === "win32" ? "junction" : "dir", + ); + + expect( + await resolveTrustedExecutable( + "git", + { PATH: [...unsafe, trusted].join(delimiter), KEEP: "ok" }, + repositories, + ), + ).toEqual({ + executable: join(trusted, name), + environment: { PATH: trusted, KEEP: "ok" }, + }); + for (const path of [...unsafe, linked]) { + const inspected = await inspectTrustedExecutable( + join(path, name), + { PATH: trusted }, + repositories, + { preserveInvocation: true }, + ); + expect(inspected.executable).toBeNull(); + } + expect( + ( + await inspectTrustedExecutable( + join(trusted, name), + { PATH: trusted }, + repositories, + { preserveInvocation: true }, + ) + ).executable, + ).toBe(join(trusted, name)); + + const sourceFile = join(root, "source-file"); + await link(join(trusted, name), sourceFile); + expect( + ( + await inspectTrustedExecutable( + join(trusted, name), + { PATH: "" }, + [repositories[0]!, sourceFile], + { preserveInvocation: true }, + ) + ).executable, + ).toBeNull(); + }); + test("accepts safe relative PATH entries without trusting repository links", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -146,6 +276,229 @@ describe("trusted executable resolution", () => { }, ); + test("preserves explicit invocations only outside the protected directory identity", async () => { + const name = + "preserves explicit invocations only outside the protected directory identity"; + if (runMockInSubprocess(import.meta.path, name)) return; + + const originalPromises = { ...fsPromises }; + const root = join(tmpdir(), "trusted-invocation-metadata-mock"); + const repository = join(root, "repository"); + const trusted = join(root, "trusted"); + const alias = join(root, "repository-alias"); + const caseAlias = join(root, "REPOSITORY"); + const extension = process.platform === "win32" ? ".exe" : ""; + const canonical = join(root, "native", `tool${extension}`); + const caseTarget = join(caseAlias, "native", `tool${extension}`); + const selected = join(trusted, "selected git"); + const inside = join(repository, "git"); + const linkedParent = join(repository, "host-tools", "git"); + const aliasedParent = join(alias, "git"); + const caseParent = join(caseAlias, "git"); + const traversedParent = `${alias}${sep}..${sep}trusted${sep}git`; + const externalCaseTarget = join(trusted, "case-target"); + const unsafe = [ + inside, + linkedParent, + aliasedParent, + caseParent, + traversedParent, + externalCaseTarget, + ]; + const paths = new Map([ + [repository, repository], + ...[selected, ...unsafe].map((path): [string, string] => [ + `${path}${extension}`, + path === externalCaseTarget ? caseTarget : canonical, + ]), + ]); + const protectedInode = 9_007_199_254_740_993n; + const identities = new Map([ + [repository, protectedInode], + [alias, protectedInode], + [caseAlias, protectedInode], + [trusted, protectedInode - 1n], + ]); + let nextInode = 1n; + mock.module("node:fs/promises", () => ({ + ...originalPromises, + realpath: async (path: string) => { + const result = paths.get(path); + if (result === undefined) { + throw Object.assign(new Error("missing synthetic path"), { + code: "ENOENT", + }); + } + return result; + }, + access: async () => {}, + stat: async (path: string, options?: { bigint?: boolean }) => { + if (!options?.bigint) { + return { isFile: () => path === canonical || path === caseTarget }; + } + if (!identities.has(path)) identities.set(path, nextInode++); + return { dev: 1n, ino: identities.get(path)! }; + }, + })); + + try { + const resolver = await import("../src/trusted-executable.js"); + const inspect = (path: string, preserveInvocation = true) => + resolver.inspectTrustedExecutable(path, { PATH: "" }, repository, { + preserveInvocation, + }); + expect((await inspect(selected)).executable).toBe(selected); + expect((await inspect(inside, false)).executable).toBe(canonical); + for (const path of unsafe) { + expect((await inspect(path)).executable).toBeNull(); + } + } finally { + mock.module("node:fs/promises", () => originalPromises); + } + }); + + 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"); 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 000000000..5f78ce18c --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-tool-environment.test.ts @@ -0,0 +1,345 @@ +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, 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" }, + ); + 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("accepts extensionless bindings and quoted Windows tool paths", () => { + runPythonMocks(` +import tempfile +with tempfile.TemporaryDirectory(prefix="workbench-tool-paths-") as temporary: + root = Path(temporary).resolve() + repository = root / "repository" + directory = root / "tools with spaces" + repository.mkdir() + directory.mkdir() + for name in ("git", "rg"): + (directory / (name + ".exe")).write_bytes(b"synthetic native executable") + (directory / "missing.com").write_bytes(b"synthetic native executable") + with ( + patch.object(workbench.sys, "platform", "win32"), + patch.object(workbench.subprocess, "run") as run, + ): + for name in ("git", "rg"): + invocation = str(directory / name) + setting = "CODEX_SECURITY_" + name.upper() + assert workbench._trusted_executable( + repository, {"PATH": "", setting: invocation}, name, + ) == invocation + environment = {"PATH": '"' + str(directory) + '"'} + assert workbench._trusted_executable( + repository, environment, name, + ) == str(directory / (name + ".exe")) + assert environment["PATH"] == str(directory) + try: + workbench._trusted_executable( + repository, {setting: str(directory / "missing")}, name, + ) + except SystemExit: + pass + else: + raise AssertionError("extensionless invocation incorrectly selected a .com file") + run.assert_not_called() +`); + }); + + 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() +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"]), + ); + }); +}); 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 000000000..b41184171 --- /dev/null +++ b/sdk/typescript/tests-ts/workbench-trusted-git.test.ts @@ -0,0 +1,876 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + 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 { resolvePluginPython, runWorkbench } from "../src/runtime.js"; +import { inspectTrustedExecutable } from "../src/trusted-executable.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), + ); + } + + 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", () => { + 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("treats cyclic saved targets as unavailable Git probes", () => { + const target = fixture(); + const result = probe( + target, + [ + "import errno, workbench_target", + "from unittest.mock import patch", + "statuses = []", + "for failure in (RuntimeError('synthetic symlink loop'), OSError(errno.ELOOP, 'synthetic symlink loop')):", + " with patch.object(Path, 'resolve', side_effect=failure), patch.object(workbench_target.subprocess, 'run', side_effect=AssertionError('unexpected Git execution')):", + " statuses.append(git_command(Path(sys.argv[2]), 'status', text=True).returncode)", + "permission_error = False", + "try:", + " with patch.object(Path, 'resolve', side_effect=PermissionError(errno.EACCES, 'synthetic permission error')):", + " git_command(Path(sys.argv[2]), 'status', text=True)", + "except PermissionError:", + " permission_error = True", + "print(json.dumps({'statuses': statuses, 'permissionError': permission_error}))", + ], + { environment: { CODEX_SECURITY_GIT: target.git } }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + statuses: [127, 127], + permissionError: true, + }); + }); + + test("handles cyclic configured executables and PATH entries", () => { + const target = fixture(); + const result = probe(target, [ + "import errno, workbench_target", + "from unittest.mock import patch", + "repository = Path(sys.argv[2])", + "directory = repository.parent", + "loop = directory / ('git.exe' if sys.platform == 'win32' else 'git')", + "original_resolve = Path.resolve", + "outcomes = []", + "for failure in (RuntimeError('synthetic symlink loop'), OSError(errno.ELOOP, 'synthetic symlink loop')):", + " def resolve(path, *args, **kwargs):", + " if path == loop:", + " raise failure", + " return original_resolve(path, *args, **kwargs)", + " with patch.object(Path, 'resolve', resolve):", + " configured = None", + " try:", + " workbench_target._trusted_git_executable(repository, {'CODEX_SECURITY_GIT': str(loop), 'PATH': ''})", + " except SystemExit as error:", + " configured = str(error)", + " from_path = workbench_target._trusted_git_executable(repository, {'PATH': str(directory)})", + " def resolve_directory(path, *args, **kwargs):", + " if path == directory:", + " raise failure", + " return original_resolve(path, *args, **kwargs)", + " with patch.object(Path, 'resolve', resolve_directory):", + " from_directory = workbench_target._trusted_git_executable(repository, {'PATH': str(directory)})", + " outcomes.append({'configured': configured, 'path': from_path, 'directory': from_directory})", + "print(json.dumps(outcomes))", + ]); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual( + Array.from({ length: 2 }, () => ({ + configured: "CODEX_SECURITY_GIT does not name an available executable.", + path: null, + directory: null, + })), + ); + }); + + test("uses default lookup only when PATH is absent", async () => { + const target = fixture(); + const defaultPath = + process.platform === "win32" ? process.env["PATH"] : "/usr/bin:/bin"; + const expected = await inspectTrustedExecutable( + "git", + { HOME: target.root, PATH: defaultPath ?? "" }, + target.repository, + ); + const missing = await inspectTrustedExecutable( + "git", + { HOME: target.root }, + target.repository, + ); + expect(missing).toEqual(expected); + + const undefinedPath = await inspectTrustedExecutable( + "git", + { HOME: target.root, PATH: undefined }, + target.repository, + ); + 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", () => { + 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("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"); + 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 }, + }); + 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( + "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", + () => { + 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 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 () => { + 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 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 [ + gitAliasDirectory, + aliasDirectory, + safeDirectory, + codexHome, + ]) { + mkdirSync(directory); + } + symlinkSync(target.shim, join(gitAliasDirectory, "git")); + 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, + gitAliasDirectory, + 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); + 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]); + } + 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 }); + }, + ); +});