diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 04c30b9c..4015ea14 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.20", + "version": "0.1.21", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 05faf6e0..5d11f52b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -134,11 +134,18 @@ def git_command( for name in GIT_REPOSITORY_ENVIRONMENT: environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" + executable = environment.pop("CODEX_SECURITY_GIT_EXECUTABLE", "git") + selected_path = environment.pop("CODEX_SECURITY_GIT_PATH", None) + if selected_path is not None: + environment["PATH"] = selected_path # Repository-local config is untrusted; fsmonitor may name an executable hook. - command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)] + command = [executable, "-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] + if not executable: + empty_output = "" if text else b"" + return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) try: return subprocess.run( full_command, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a826fafb..87dc98b6 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -813,6 +813,7 @@ export class CodexSecurity { const workbenchOptions: WorkbenchCommandOptions = { python, pluginRoot: runtime.plugin.pluginRoot, + protectedRoot, environment: { ...selectedScanEnvironment( runtime.environment, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 13c41f2c..c4fbac3b 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -91,6 +91,7 @@ export interface WorkbenchCommandOptions { python: string; pluginRoot: string; environment: ProcessEnvironment; + protectedRoot?: string; signal?: AbortSignal; failureMessage?: string; } @@ -1292,6 +1293,39 @@ export async function runWorkbench( ): Promise { let stdout: string; try { + let environment = Object.fromEntries( + Object.entries(options.environment).filter( + ([name]) => + name.toUpperCase() !== "OPENAI_API_KEY" && + name.toUpperCase() !== "CODEX_API_KEY" && + name.toUpperCase() !== "OPENROUTER_API_KEY" && + name.toUpperCase() !== "FIREWORKS_API_KEY", + ), + ); + if (options.protectedRoot !== undefined) { + if ( + !Object.keys(environment).some((name) => name.toUpperCase() === "PATH") + ) { + const inheritedPath = Object.entries(process.env).find( + ([name]) => name.toUpperCase() === "PATH", + )?.[1]; + if (inheritedPath !== undefined) environment["PATH"] = inheritedPath; + } + const python = await resolveTrustedExecutable( + options.python, + environment, + options.protectedRoot, + ); + if (python !== null) environment = python.environment; + const git = await resolveTrustedExecutable( + "git", + environment, + options.protectedRoot, + ); + environment["CODEX_SECURITY_GIT_EXECUTABLE"] = git?.executable ?? ""; + environment["CODEX_SECURITY_GIT_PATH"] = + git?.environment["PATH"] ?? environment["PATH"] ?? ""; + } ({ stdout } = await execFile( options.python, [ @@ -1301,15 +1335,7 @@ export async function runWorkbench( ...args, ], { - env: Object.fromEntries( - Object.entries(options.environment).filter( - ([name]) => - name.toUpperCase() !== "OPENAI_API_KEY" && - name.toUpperCase() !== "CODEX_API_KEY" && - name.toUpperCase() !== "OPENROUTER_API_KEY" && - name.toUpperCase() !== "FIREWORKS_API_KEY", - ), - ), + env: environment, encoding: "utf8", maxBuffer: Infinity, windowsHide: true, diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 01dd5006..955feef3 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -8,7 +8,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.20" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.21" as const; const PACKAGE_NAME = "@openai/codex-security"; const VERSION_PATTERN = diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70..3d58a6d2 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -3809,6 +3809,113 @@ describe("runtime directories and plugin Python boundary", () => { expect(result["details"]).toHaveLength(5 * 1024 * 1024); }); + test("does not execute repository-local Git shims in the trusted workbench", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const untrusted = join(repository, "node_modules", ".bin"); + const pluginRoot = join(root, "plugin"); + await mkdir(untrusted, { recursive: true }); + await mkdir(join(pluginRoot, "scripts"), { recursive: true }); + const fakeGit = join( + untrusted, + process.platform === "win32" ? "git.cmd" : "git", + ); + await writeFile( + fakeGit, + process.platform === "win32" + ? "@echo repository-controlled-git\r\n" + : "#!/bin/sh\necho repository-controlled-git\n", + { mode: 0o755 }, + ); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, sys", + "from pathlib import Path", + "sys.path.insert(0, os.environ['CODEX_SECURITY_TEST_PLUGIN_SCRIPTS'])", + "from workbench_target import git_output", + "git = git_output(Path(os.environ['CODEX_SECURITY_TEST_REPOSITORY']), '--version')", + "print(json.dumps({'git': git, 'path': os.environ.get('PATH')}))", + ].join("\n"), + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const result = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + CODEX_SECURITY_TEST_PLUGIN_SCRIPTS: join(PLUGIN_ROOT, "scripts"), + CODEX_SECURITY_TEST_REPOSITORY: repository, + PATH: [untrusted, process.env["PATH"]] + .filter(Boolean) + .join(delimiter), + }, + }, + ["test-command"], + ); + + expect(result["git"]).toStartWith("git version "); + expect(String(result["path"]).split(delimiter)).not.toContain(untrusted); + + const withoutGit = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + CODEX_SECURITY_TEST_PLUGIN_SCRIPTS: join(PLUGIN_ROOT, "scripts"), + CODEX_SECURITY_TEST_REPOSITORY: repository, + PATH: untrusted, + }, + }, + ["test-command"], + ); + expect(withoutGit).toMatchObject({ git: null, path: "" }); + + const inherited = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + CODEX_SECURITY_TEST_PLUGIN_SCRIPTS: join(PLUGIN_ROOT, "scripts"), + CODEX_SECURITY_TEST_REPOSITORY: repository, + }, + }, + ["test-command"], + ); + expect(inherited["git"]).toStartWith("git version "); + + if (process.platform !== "win32") { + const helpers = join(root, "trusted-helpers"); + const launcher = join(root, "trusted-python"); + await mkdir(helpers); + await symlink(Bun.which("sh")!, join(helpers, "sh")); + await writeFile( + launcher, + `#!/usr/bin/env sh\nexec ${JSON.stringify(python)} "$@"\n`, + { mode: 0o755 }, + ); + const configured = await runWorkbench( + { + python: launcher, + pluginRoot, + protectedRoot: repository, + environment: { + CODEX_SECURITY_TEST_PLUGIN_SCRIPTS: join(PLUGIN_ROOT, "scripts"), + CODEX_SECURITY_TEST_REPOSITORY: repository, + PATH: helpers, + }, + }, + ["test-command"], + ); + expect(configured).toMatchObject({ git: null, path: helpers }); + } + }); + test("upgrades colliding legacy execution-profile and public CLI migrations", async () => { const root = await temporaryDirectory("codex-security-legacy-migrations-"); const repository = join(root, "repository");