diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 index 0d8b425a..65ea3853 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 differ diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 index d43827a7..916cd6a7 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 differ diff --git a/sdk/typescript/_bundled_plugin/references/config-preflight.md b/sdk/typescript/_bundled_plugin/references/config-preflight.md index f6f66a73..169f5886 100644 --- a/sdk/typescript/_bundled_plugin/references/config-preflight.md +++ b/sdk/typescript/_bundled_plugin/references/config-preflight.md @@ -60,7 +60,7 @@ When the profile includes remediation patches, present the concrete config delta Some remediation patches have `kind = "host_setting"`. Present those as host-level setup guidance, not as edits to persistent Codex config. -Deep Security Scan uses MCP-owned SDK sessions rather than the parent thread's worker pool. Its preflight does not require a particular parent delegation runtime, ownership, capacity, or depth. Discovery workers inherit the scan's model and run under the verified read-only worker sandbox. +Deep Security Scan uses MCP-owned SDK sessions rather than the parent thread's worker pool. Its preflight does not require a particular parent delegation runtime, ownership, capacity, or depth. Discovery workers inherit the scan's model and use the reserved `codex_security_deep_scan_worker` permission profile with the parent's supported filesystem denials. The selected Codex executable must support permission-profile configuration and allowance checks. If that command fails to start or exits early, report its path and the tool's diagnostic; do not infer that its version is unsupported. If the tool identifies a missing API, ask the user to update the Codex installation at the reported path: the desktop app for an app-bundled executable, or the selected CLI otherwise. If Codex policy rejects the profile, report the tool's administrator guidance. Do not remove deny rules or select a broader sandbox to work around the error. Do not warn merely because a user's value differs from the profile's suggested patch. Warn or block only when the evaluated capability requirement is unmet. diff --git a/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts b/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts new file mode 100644 index 00000000..7799a42e --- /dev/null +++ b/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts @@ -0,0 +1,131 @@ +import * as path from "node:path"; +import * as url from "node:url"; +import * as util from "node:util"; +import { expect, test } from "bun:test"; +import { parse } from "smol-toml"; +import { loadBundledRuntime } from "./plugin-root.js"; + +type Sandbox = { filesystemDenies: string[]; globScanMaxDepth?: number }; + +async function bundledPolicy() { + const runtime = await loadBundledRuntime(); + const start = runtime.indexOf("var CODEX_SANDBOX_STATE_META_CAPABILITY ="); + const end = runtime.indexOf("\n// ", start); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + const source = runtime.slice(start, end); + const imports = [ + ...new Set(source.match(/import_node_(?:path|url|util)\d*/gu)), + ]; + const resolve = new Function( + ...imports, + "DeepScanNonRetryableError", + `${source}\nreturn resolveDeepWorkerParentSandbox;`, + )( + ...imports.map((name) => + name.startsWith("import_node_path") + ? path + : name.startsWith("import_node_url") + ? url + : util, + ), + Error, + ) as (metadata: unknown) => Sandbox; + const serializer = [ + "workerPermissionProfile", + "workerPermissionProfileConfigOverrides", + "tomlInlineValue", + "tomlKey", + "tomlString", + ] + .map((name) => { + const definition = new RegExp( + `function ${name}\\([^\\n]*\\) \\{[\\s\\S]*?\\n\\}`, + "u", + ).exec(runtime)?.[0]; + if (!definition) throw new Error(`Missing bundled function: ${name}`); + return definition; + }) + .join("\n"); + const overrides = new Function( + "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", + `${serializer}\nreturn sandbox => workerPermissionProfileConfigOverrides(workerPermissionProfile(sandbox));`, + )("codex_security_deep_scan_worker") as (sandbox: Sandbox) => string[]; + return { resolve, overrides }; +} + +function metadata(entries: unknown[]) { + return { + _meta: { + "codex/sandbox-state-meta": { + permissionProfile: { + type: "managed", + network: "enabled", + file_system: { + type: "restricted", + glob_scan_max_depth: 8, + entries: [ + { + access: "read", + path: { type: "special", value: { kind: "root" } }, + }, + ...entries, + ], + }, + }, + }, + }, + }; +} + +test("preserves literal parent deny paths and globs without parent write grants", async () => { + const policy = await bundledPolicy(); + const denied = path.resolve("synthetic", "secret.with.dots"); + const glob = path.resolve("synthetic", "**", "*.secret"); + const sandbox = policy.resolve( + metadata([ + { + access: "write", + path: { type: "path", path: path.resolve("synthetic") }, + }, + { access: "deny", path: { type: "path", path: denied } }, + { access: "none", path: { type: "glob_pattern", pattern: glob } }, + ]), + ); + expect(sandbox).toEqual({ + filesystemDenies: [denied, glob], + globScanMaxDepth: 8, + }); + expect(parse(policy.overrides(sandbox).join("\n"))).toEqual({ + default_permissions: "codex_security_deep_scan_worker", + permissions: { + codex_security_deep_scan_worker: { + extends: ":read-only", + filesystem: { + ":root": "read", + [denied]: "deny", + [glob]: "deny", + glob_scan_max_depth: 8, + }, + network: { enabled: false }, + }, + }, + }); +}); + +test("rejects parent denials that cannot be preserved", async () => { + const policy = await bundledPolicy(); + for (const entry of [ + { access: "deny", path: { type: "path", path: "relative/secret" } }, + { access: "deny", path: { type: "special", value: { kind: "tmpdir" } } }, + { + access: "write", + path: { type: "glob_pattern", pattern: path.resolve("synthetic", "*") }, + }, + ]) { + expect(() => policy.resolve(metadata([entry]))).toThrow( + "cannot be preserved", + ); + } + expect(() => policy.resolve({})).toThrow("trusted parent sandbox metadata"); +}); diff --git a/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts b/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts new file mode 100644 index 00000000..5556afee --- /dev/null +++ b/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts @@ -0,0 +1,120 @@ +import * as fs from "node:fs"; +import { + mkdir, + mkdtemp, + realpath, + rm, + utimes, + writeFile, +} from "node:fs/promises"; +import * as module from "node:module"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { expect, test } from "bun:test"; +import { loadBundledRuntime } from "./plugin-root.js"; + +async function bundledResolver() { + const runtime = await loadBundledRuntime(); + const start = runtime.indexOf("function resolveCodexPath("); + const end = runtime.indexOf("\n// server.ts", start); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + const source = runtime.slice(start, end); + const imports = [ + ...new Set(source.match(/import_node_(?:fs|path|module)\d*/gu)), + ]; + return new Function(...imports, `${source}\nreturn resolveCodexPath;`)( + ...imports.map((name) => + name.startsWith("import_node_fs") + ? fs + : name.startsWith("import_node_path") + ? path + : module, + ), + ) as ( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + ) => string; +} + +test("uses the newest relocated Windows executable when WindowsApps is inaccessible", async () => { + const root = await realpath( + await mkdtemp(path.join(tmpdir(), "codex-security-executable-")), + ); + try { + const older = path.join( + root, + "OpenAI", + "Codex", + "bin", + "11111111", + "codex.exe", + ); + const newer = path.join( + root, + "OpenAI", + "Codex", + "bin", + "22222222", + "codex.exe", + ); + const empty = path.join( + root, + "OpenAI", + "Codex", + "bin", + "33333333", + "codex.exe", + ); + for (const executable of [older, newer, empty]) { + await mkdir(path.dirname(executable), { recursive: true }); + await writeFile( + executable, + executable === empty ? "" : "synthetic executable", + ); + } + await utimes(older, 1, 1); + await utimes(newer, 2, 2); + const resolve = await bundledResolver(); + const environment = { + PATH: "", + LOCALAPPDATA: root, + CODEX_CLI_PATH: "C:\\Program Files\\WindowsApps\\Codex\\codex.exe", + }; + expect(resolve(environment, "win32", "x64")).toBe(newer); + expect( + resolve( + { ...environment, CODEX_CLI_PATH: "C:\\Tools\\codex.exe" }, + "win32", + "x64", + ), + ).toBe("C:\\Tools\\codex.exe"); + expect(resolve({ PATH: "" }, "win32", "x64")).toBe( + path.resolve("codex.exe"), + ); + expect(resolve({}, "linux", "x64")).toBe(path.resolve("codex")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("does not retry Windows executable permission failures", async () => { + const runtime = await loadBundledRuntime(); + const source = + /function classifyCodexWorkerError\([^\n]*\) \{[\s\S]*?\n\}/u.exec( + runtime, + )?.[0]; + expect(source).toBeDefined(); + class NonRetryableError extends Error {} + const classify = new Function( + "DeepScanNonRetryableError", + `${source}\nreturn classifyCodexWorkerError;`, + )(NonRetryableError) as (error: Error) => Error; + const original = Object.assign(new Error("spawn codex EPERM"), { + code: "EPERM", + }); + const result = classify(original); + expect(result).toBeInstanceOf(NonRetryableError); + expect(result.cause).toBe(original); +}); diff --git a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts index a77c6e82..e3fc9cb5 100644 --- a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts @@ -8,7 +8,7 @@ type WorkerEvent = | { type: "turn.failed"; error: { message: string } }; type WorkerExecutorConstructor = new (settings: { - parentSandbox: { filesystem: "workspace-write"; network: "restricted" }; + parentSandbox: { filesystemDenies: string[] }; }) => { run(request: { kind: "discovery"; @@ -22,6 +22,7 @@ type WorkerExecutorConstructor = new (settings: { async function bundledWorkerExecutor( events: (signal: AbortSignal) => AsyncGenerator, + preflight = async () => {}, ): Promise { const runtime = await loadBundledRuntime(); const source = /var CodexSdkWorkerExecutor = class \{[\s\S]*?\n\};/u.exec( @@ -49,7 +50,12 @@ async function bundledWorkerExecutor( return new Function( "Codex", fileSystemImport!, - "assertVerifiedParentSandbox", + "workerPermissionProfile", + "workerPermissionProfileConfigOverrides", + "snapshotWorkerEnvironment", + "preflightDeepScanWorkerPermissionProfile", + "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", + "deepScanPermissionProfileFallbackError", "resolveCodexPath", "workerSubagentConfig", "appendSafeItemDiagnostic", @@ -58,7 +64,12 @@ async function bundledWorkerExecutor( )( FakeCodex, { promises: { readFile: async () => "fixture worker prompt" } }, - () => {}, + () => ({}), + () => [], + async () => ({}), + preflight, + "codex_security_deep_scan_worker", + () => undefined, () => "/fixture/codex", () => ({}), () => {}, @@ -72,7 +83,7 @@ function runWorker( onThreadStarted?: () => void, ) { return new WorkerExecutor({ - parentSandbox: { filesystem: "workspace-write", network: "restricted" }, + parentSandbox: { filesystemDenies: [] }, }).run({ kind: "discovery", promptPath: "/fixture/prompt.md", @@ -83,6 +94,23 @@ function runWorker( }); } +test("does not start a bundled worker when its permission profile check fails", async () => { + let started = false; + const WorkerExecutor = await bundledWorkerExecutor( + async function* () { + started = true; + yield { type: "turn.completed" }; + }, + async () => { + throw new Error("worker permission profile rejected"); + }, + ); + await expect( + runWorker(WorkerExecutor, new AbortController().signal), + ).rejects.toThrow("worker permission profile rejected"); + expect(started).toBe(false); +}); + test("settles completed bundled Deep Scan workers during coordinator cancellation", async () => { const parentController = new AbortController(); let workerSignal: AbortSignal | undefined;