diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 0ab2dccf3..10b3eb1e8 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -196,9 +196,13 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { const thread = request.resumeThreadId ? codex.resumeThread(request.resumeThreadId, threadOptions) : codex.startThread(threadOptions); - const input = request.resumeThreadId + const workerPrompt = request.resumeThreadId ? (request.continuationPrompt ?? prompt) : prompt; + const pluginRoot = this.modelSettings.artifactContext?.pluginRoot; + const input = pluginRoot + ? `${workerPrompt}\n\nFor a Codex Security skill invoked by the assigned workflow, remove any codex-security: prefix from its name and read /SKILL.md beneath ${JSON.stringify(join(pluginRoot, "skills"))}. Follow that file and resolve its relative references from its directory. Give delegated workers the same readable skill paths. Load only skills required by the assigned workflow; this does not expand the worker's assigned role.` + : workerPrompt; const controller = new AbortController(); const forwardAbort = () => controller.abort(request.signal.reason); if (request.signal.aborted) { @@ -545,6 +549,7 @@ async function workerRuntimeSettings( "service_tier", "analytics", "responses_api_metadata", + "skills", ].map((key) => [key, selected[key]]), ); const settings: CodexSdkWorkerRuntimeSettings = { config: inherited }; diff --git a/plugins/codex-security/mcp-app/tests/support/fake-codex-source.ts b/plugins/codex-security/mcp-app/tests/support/fake-codex-source.ts new file mode 100644 index 000000000..fe1cde063 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/support/fake-codex-source.ts @@ -0,0 +1,120 @@ +import { jsonLineInputSource } from "./json-line-input-source.ts"; + +export function fakeCodexSource( + preflightProfile: unknown, + preflightAllowed: boolean, + accountResult: unknown, + preflightMarkerPath: string, +) { + return `#!/usr/bin/env node +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +const preflightProfile = process.env.FAKE_CODEX_PREFLIGHT_PROFILE ? JSON.parse(process.env.FAKE_CODEX_PREFLIGHT_PROFILE) : ${JSON.stringify(preflightProfile)}; +const preflightAllowed = ${JSON.stringify(preflightAllowed)}; +const accountResult = ${JSON.stringify(accountResult)}; +const preflightMarkerPath = process.env.FAKE_CODEX_PREFLIGHT_MARKER ?? ${JSON.stringify(preflightMarkerPath)}; +if (process.argv.includes('app-server')) { + const preflight = { argv: process.argv.slice(2), configPath: process.env.CODEX_SECURITY_CONFIG_PATH, runnerTrackingId: process.env.RUNNER_TRACKING_ID, libraryPath: process.env.LD_LIBRARY_PATH, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, providerKey: process.env.SYNTHETIC_GATEWAY_KEY, providerHeader: process.env.SYNTHETIC_HEADER_VALUE, cacheDirectory: process.env.XDG_CACHE_HOME, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), requests: [] }; + writeFileSync(preflightMarkerPath, JSON.stringify(preflight)); +${jsonLineInputSource(` if (message.method === 'initialized') continue; + if (message.method === 'config/read' || message.method === 'permissionProfile/list') { + preflight.requests.push({ method: message.method, cwd: message.params?.cwd }); + writeFileSync(preflightMarkerPath, JSON.stringify(preflight)); + } + let result; + if (message.method === 'initialize') { + result = { userAgent: 'fixture', codexHome: '/fixture', platformFamily: 'unix', platformOs: 'macos' }; + } else if (message.method === 'config/read') { + result = { config: { default_permissions: 'codex_security_deep_scan_worker', permissions: { codex_security_deep_scan_worker: preflightProfile } }, origins: {}, layers: null }; + } else if (message.method === 'permissionProfile/list') { + result = { data: [{ id: 'codex_security_deep_scan_worker', description: null, allowed: preflightAllowed }], nextCursor: null }; + } else if (message.method === 'account/read') { + result = accountResult; + } else if (message.method === 'configRequirements/read') { + result = { requirements: { allowedPermissionProfiles: { existing_profile: true } } }; + } else { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }) + '\\n'); + continue; + } + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\\n');`)} + process.stdin.on('end', () => process.exit(0)); +} else { +const stdin = (await process.stdin.toArray()).join(''); +let scopeInput; +{ + const { parse } = await import(${JSON.stringify(import.meta.resolve("smol-toml"))}); + const configuration = process.argv.flatMap((arg, index) => arg === '--config' || arg === '-c' ? [parse(process.argv[index + 1])] : []).findLast(value => value.mcp_servers?.cs_artifacts); + const file = configuration?.mcp_servers.cs_artifacts.env.CODEX_SECURITY_INCLUDE_PATHS_FILE; + if (file) scopeInput = { file, ...JSON.parse(readFileSync(file, 'utf8')) }; +} + +const profileIndex = process.argv.indexOf('--profile'); +const profileContents = profileIndex === -1 ? undefined : readFileSync(join(process.env.CODEX_HOME, process.argv[profileIndex + 1] + '.config.toml'), 'utf8'); +const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined; +const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined; +const runtimeEnvironment = Object.fromEntries(['PATH', 'HOME', 'PYTHON', 'PYTHONUTF8', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'DYLD_FALLBACK_LIBRARY_PATH', 'CODEX_SECURITY_STATE_DIR', 'RUNNER_TRACKING_ID'].map(name => [name, process.env[name]])); +const toolProbe = stdin.includes('CAPTURE_SYNTHETIC_RG') ? spawnSync('rg', ['--version'], { encoding: 'utf8' }) : undefined; +if (toolProbe && toolProbe.status !== 0) throw toolProbe.error ?? new Error(toolProbe.stderr); +const pythonProbe = stdin.includes('CAPTURE_SYNTHETIC_PYTHON') ? spawnSync(process.env.PYTHON, ['-I', '-c', 'import json,os,sys; print(json.dumps([sys.prefix,os.environ.get("LD_LIBRARY_PATH")]))'], { encoding: 'utf8' }) : undefined; +if (pythonProbe && pythonProbe.status !== 0) throw new Error(pythonProbe.stderr || String(pythonProbe.error)); +const pythonRuntime = pythonProbe ? JSON.parse(pythonProbe.stdout) : undefined; +const knowledgePath = stdin.includes('synthetic worker configuration fixture') ? process.env.CODEX_SECURITY_KNOWLEDGE_BASE : undefined; +const knowledgeDocuments = knowledgePath === undefined ? undefined : Object.fromEntries(readdirSync(knowledgePath).map(name => [name, readFileSync(join(knowledgePath, name), 'utf8')])); +writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ ...(scopeInput === undefined ? {} : { scopeInput }), argv: process.argv.slice(2), stdin, cwd: process.cwd(), knowledgePath, knowledgeDocuments, codexHome: process.env.CODEX_HOME, cacheDirectory: process.env.XDG_CACHE_HOME, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), configPath: process.env.CODEX_SECURITY_CONFIG_PATH, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, python: process.env.PYTHON, mcpNodePath: process.env.CODEX_MCP_NODE_PATH, bundledTool: toolProbe?.stdout.trim(), pythonPrefix: pythonRuntime?.[0], pythonLibraryPath: pythonRuntime?.[1], runtimeEnvironment, providerKey: process.env.SYNTHETIC_GATEWAY_KEY, providerHeader: process.env.SYNTHETIC_HEADER_VALUE, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(profileContents === undefined ? {} : { profileContents }), ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) })); +if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => { if (!stdin.includes('IGNORE_TERMINATION')) setTimeout(() => process.exit(0), 100); }); + +if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); } +if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); } +if (stdin.includes('MCP_STARTUP_TIMEOUT') || stdin.includes('CATALOG_AUTH_ONLY') || stdin.includes('SYNC_AUTH_ONLY')) { + const syncWarning = stdin.includes('SYNC_AUTH_WARNING') || stdin.includes('SYNC_AUTH_ONLY'); + const bothWarnings = stdin.includes('BOTH_AUTH_WARNINGS'); + if (!syncWarning || bothWarnings) console.error('chatgpt authentication required for remote plugin catalog; api key auth is not supported'); + if (syncWarning || bothWarnings) console.error('chatgpt authentication required to sync remote plugins; api key auth is not supported'); + if (!stdin.includes('CATALOG_AUTH_ONLY') && !stdin.includes('SYNC_AUTH_ONLY')) { + const serverName = stdin.includes('OTHER_MCP_STARTUP_TIMEOUT') ? 'other_server' : stdin.includes('LEGACY_ARTIFACT_MCP_STARTUP_TIMEOUT') ? 'codex_security_artifacts' : 'cs_artifacts'; + const timeout = stdin.includes('REQUEST_TIMED_OUT') ? 'request timed out' : 'timed out handshaking with MCP server after 30s'; + console.error('required MCP servers failed to initialize: ' + serverName + ': ' + timeout); + } + if (stdin.includes('WITH_MISSING_API_KEY')) console.error('missing API key'); + if (stdin.includes('WITH_POLICY_REFUSAL')) console.error('Request blocked by cyberPolicy.'); + process.exit(1); +} +const resumeIndex = process.argv.indexOf('resume'); +const threadId = resumeIndex === -1 ? 'fixture-thread-id' : process.argv[resumeIndex + 1]; +console.log(JSON.stringify({ type: 'thread.started', thread_id: threadId })); +if (stdin.includes('IPC_DIAGNOSTIC_EVENT')) console.log(stdin.split('\\n')[1]); +if (process.env.FAKE_CODEX_ARTIFACT_EVENT) console.log(process.env.FAKE_CODEX_ARTIFACT_EVENT); +if (stdin.includes('MALFORMED_COMMAND_EVENT')) { + const output = JSON.parse(stdin.split('\\n')[1]); + const event = { type: 'item.completed', item: { id: 'fixture-command', type: 'command_execution', command: 'cat example.ts', aggregated_output: output, exit_code: 0, status: 'completed' } }; + console.log(JSON.stringify(event).slice(0, -1)); + process.exit(0); +} +const permissionProfileFallbackWarning = 'Configured value for \`permission_profile\` is disallowed by requirements; falling back from \`codex_security_deep_scan_worker\` to required value \`:read-only\`.'; +if (stdin.includes('PERMISSION_PROFILE_FALLBACK_ITEM')) console.log(JSON.stringify({ type: 'item.completed', item: { id: 'warning-1', type: 'error', message: permissionProfileFallbackWarning } })); +if (stdin.includes('PERMISSION_PROFILE_FALLBACK_EVENT')) console.log(JSON.stringify({ type: 'error', message: permissionProfileFallbackWarning })); +if (stdin.includes('BLOCK_AFTER_START')) await new Promise(() => {}); +if (stdin.includes('FAIL_THEN_HANG')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'fixture worker failed' } })); setInterval(() => {}, 1_000); await new Promise(() => {}); } +if (stdin.includes('RATE_LIMIT_CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: '429 Too Many Requests: Request blocked by cyberPolicy.' } })); process.exit(0); } +if (stdin.includes('CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by cyberPolicy.' } })); process.exit(0); } +if (stdin.includes('SAFETY_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by a safety policy violation.' } })); process.exit(0); } +if (stdin.includes('UPSTREAM_CYBERSECURITY_RISK_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This request has been flagged for possible cybersecurity risk.' } })); process.exit(0); } +if (stdin.includes('UPSTREAM_HIGH_RISK_CYBER_ACTIVITY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This request has been flagged for potentially high-risk cyber activity.' } })); process.exit(0); } +if (stdin.includes('CYBERSECURITY_RISK_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This content was flagged for possible cybersecurity risk.' } })); process.exit(0); } +if (stdin.includes('HIGH_RISK_CYBER_ACTIVITY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This content was flagged for potentially high-risk cyber activity.' } })); process.exit(0); } +if (stdin.includes('RETRYABLE_STREAM_ERROR')) console.log(JSON.stringify({ type: 'error', message: 'Reconnecting... 2/5 (stream disconnected before completion: websocket closed by server before response.completed)' })); +if (stdin.includes('INCOMPLETE_STREAM')) { console.log(JSON.stringify({ type: 'error', message: 'fixture stream interrupted' })); process.exit(0); } +if (stdin.includes('BWRAP_NAMESPACE_FAILURE')) console.log(JSON.stringify({ type: 'item.completed', item: { id: 'command-1', type: 'command_execution', command: 'super-secret-command', aggregated_output: 'private source text\\nbwrap: Creating new namespace failed: nesting depth or /proc/sys/user/max_user_namespaces exceeded (ENOSPC)', exit_code: 1, status: 'failed' } })); +if (stdin.includes('ARTIFACT_TOOL_')) { + const server = stdin.includes('FOREIGN_ARTIFACT_TOOL_') ? 'untrusted_server' : stdin.includes('LEGACY_OWNED_ARTIFACT_TOOL_') ? 'codex_security_artifacts' : 'cs_artifacts'; + const tool = stdin.includes('ADDITIONAL_OWNED_ARTIFACT_TOOL_') ? 'additional_codex_security_worker_tool' : stdin.includes('DISCOVERY_OWNED_ARTIFACT_TOOL_') ? 'record_codex_security_discovery_candidates' : 'record_codex_security_deep_reduction'; + const item = { id: 'mcp-1', type: 'mcp_tool_call', server, tool, arguments: { secret: 'Bearer synthetic-secret', source: 'private source text' }, result: stdin.includes('REJECTED') ? { content: [{ type: 'text', text: 'private output sk-proj-synthetic-secret' }] } : null, error: stdin.includes('TRANSPORT_FAILED') ? { message: '--provider-error transport closed sk-proj-synthetic-secret /private/customer/path' } : null, status: 'failed' }; + console.log(JSON.stringify({ type: 'item.completed', item })); +} +console.log(JSON.stringify({ type: 'item.completed', item: { id: 'message-1', type: 'agent_message', text: 'fixture final response' } })); +console.log(JSON.stringify({ type: 'turn.completed', usage: stdin.includes('NULL_USAGE') ? null : { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } })); +if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await new Promise(() => {}); } +} +`; +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 2c7aca295..f08cb1d19 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1,4 +1,4 @@ -import { jsonLineInputSource } from "./support/json-line-input-source.ts"; +import { fakeCodexSource } from "./support/fake-codex-source.ts"; import { readJson, writeJson } from "./support/json.ts"; import { randomUUID } from "node:crypto"; import { loadWorkbenchProcess } from "./support/workbench-process.ts"; @@ -1481,6 +1481,33 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, : "elevated"; const webSearch = index === 0 ? undefined : ["disabled", "cached", "live"][index % 3]; + const skills = + index === 0 && configuration === "" + ? undefined + : { + config: [ + { + path: path.join( + codexHome, + "plugins", + `scan-${index}`, + "skills", + "security-scan", + "SKILL.md", + ), + enabled: false, + }, + { + path: path.join( + fixture.root, + "other skills", + `scan-${index}`, + "SKILL.md", + ), + enabled: true, + }, + ], + }; // Cover missing settings, native config inheritance, and snapshot overrides. const mergeProfileTelemetry = index === 0 && configuration !== ""; const telemetry = @@ -1637,12 +1664,32 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, set: { SYNTHETIC_SHELL_MARKER: `scan-${index}` }, }, webSearch, + skills, features, telemetry, workerTelemetry: overrideTelemetry ? telemetry : {}, configuration: { ...parsedConfiguration, ...inheritedTelemetry, + ...(skills === undefined + ? {} + : { + skills: + index === 1 || index === 4 || mergeProfileTelemetry + ? { + config: [ + { + path: path.join( + fixture.root, + "overridden", + "SKILL.md", + ), + enabled: false, + }, + ], + } + : skills, + }), ...(index === 0 ? {} : { service_tier: index === 2 ? "flex" : serviceTier }), @@ -1656,6 +1703,9 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, ...profiles, selected: { ...profiles.selected, + ...(index === 4 || mergeProfileTelemetry + ? { skills } + : {}), ...(index === 2 ? { service_tier: serviceTier } : {}), ...(index === 4 ? telemetry : {}), ...(mergeProfileTelemetry @@ -1694,6 +1744,9 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, worker_runtime: { ...entry.authentication, ...entry.workerTelemetry, + ...(entry.skills === undefined + ? {} + : { skills: entry.skills }), ...(entry.endpoint === undefined ? {} : { openai_base_url: entry.endpoint }), @@ -1832,6 +1885,12 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, workerRequest(promptPath, fixture.root, { kind, resumeThreadId, + ...(resumeThreadId + ? { + continuationPrompt: + "synthetic worker configuration fixture\nsynthetic resumed worker context", + } + : {}), artifactContext: { root: fixture.root, layout: kind === "dedup" ? "reducer" : "worker", @@ -1961,6 +2020,19 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, web_search: workerConfigurations[index].webSearch, "windows.sandbox": workerConfigurations[index].windowsSandbox, }); + assert.deepEqual( + JSON.parse( + JSON.stringify( + parseToml( + nativeConfigOverrides(invocation.argv) + .filter((override) => /^skills[.=]/.test(override)) + .join("\n"), + ), + ), + )["skills"], + workerConfigurations[index].skills, + "Discovery, reducer and resumed concurrent launches retain their own skill selection.", + ); assert.deepEqual( JSON.parse( JSON.stringify( @@ -2050,6 +2122,29 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, ), false, ); + assert.ok( + invocation.stdin.includes( + JSON.stringify( + path.join(fixture.root, `plugin-${index}`, "skills"), + ), + ), + ); + assert.ok(invocation.stdin.includes("/SKILL.md")); + assert.ok( + invocation.stdin.includes( + resumeThreadId + ? "synthetic resumed worker context" + : "synthetic worker configuration fixture", + ), + ); + assert.equal( + invocation.stdin.includes( + JSON.stringify( + path.join(fixture.root, "ambient-plugin", "skills"), + ), + ), + false, + ); assert.deepEqual(invocation.gitEnvironment, gitEnvironment); for (const [name, value] of Object.entries(gitEnvironment)) { assert.equal(process.env[name], value); @@ -3475,117 +3570,12 @@ async function fakeCodexFixture( const scriptPath = path.join(root, "fake-codex.mjs"); await writeFile( scriptPath, - `#!/usr/bin/env node -import { readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; -import { join } from "node:path"; -const preflightProfile = process.env.FAKE_CODEX_PREFLIGHT_PROFILE ? JSON.parse(process.env.FAKE_CODEX_PREFLIGHT_PROFILE) : ${JSON.stringify(preflightProfile)}; -const preflightAllowed = ${JSON.stringify(preflightAllowed)}; -const accountResult = ${JSON.stringify(accountResult)}; -const preflightMarkerPath = process.env.FAKE_CODEX_PREFLIGHT_MARKER ?? ${JSON.stringify(preflightMarkerPath)}; -if (process.argv.includes('app-server')) { - const preflight = { argv: process.argv.slice(2), configPath: process.env.CODEX_SECURITY_CONFIG_PATH, runnerTrackingId: process.env.RUNNER_TRACKING_ID, libraryPath: process.env.LD_LIBRARY_PATH, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, providerKey: process.env.SYNTHETIC_GATEWAY_KEY, providerHeader: process.env.SYNTHETIC_HEADER_VALUE, cacheDirectory: process.env.XDG_CACHE_HOME, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), requests: [] }; - writeFileSync(preflightMarkerPath, JSON.stringify(preflight)); -${jsonLineInputSource(` if (message.method === 'initialized') continue; - if (message.method === 'config/read' || message.method === 'permissionProfile/list') { - preflight.requests.push({ method: message.method, cwd: message.params?.cwd }); - writeFileSync(preflightMarkerPath, JSON.stringify(preflight)); - } - let result; - if (message.method === 'initialize') { - result = { userAgent: 'fixture', codexHome: '/fixture', platformFamily: 'unix', platformOs: 'macos' }; - } else if (message.method === 'config/read') { - result = { config: { default_permissions: 'codex_security_deep_scan_worker', permissions: { codex_security_deep_scan_worker: preflightProfile } }, origins: {}, layers: null }; - } else if (message.method === 'permissionProfile/list') { - result = { data: [{ id: 'codex_security_deep_scan_worker', description: null, allowed: preflightAllowed }], nextCursor: null }; - } else if (message.method === 'account/read') { - result = accountResult; - } else if (message.method === 'configRequirements/read') { - result = { requirements: { allowedPermissionProfiles: { existing_profile: true } } }; - } else { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }) + '\\n'); - continue; - } - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\\n');`)} - process.stdin.on('end', () => process.exit(0)); -} else { -const stdin = (await process.stdin.toArray()).join(''); -let scopeInput; -{ - const { parse } = await import(${JSON.stringify(import.meta.resolve("smol-toml"))}); - const configuration = process.argv.flatMap((arg, index) => arg === '--config' || arg === '-c' ? [parse(process.argv[index + 1])] : []).findLast(value => value.mcp_servers?.cs_artifacts); - const file = configuration?.mcp_servers.cs_artifacts.env.CODEX_SECURITY_INCLUDE_PATHS_FILE; - if (file) scopeInput = { file, ...JSON.parse(readFileSync(file, 'utf8')) }; -} - -const profileIndex = process.argv.indexOf('--profile'); -const profileContents = profileIndex === -1 ? undefined : readFileSync(join(process.env.CODEX_HOME, process.argv[profileIndex + 1] + '.config.toml'), 'utf8'); -const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined; -const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined; -const runtimeEnvironment = Object.fromEntries(['PATH', 'HOME', 'PYTHON', 'PYTHONUTF8', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'DYLD_FALLBACK_LIBRARY_PATH', 'CODEX_SECURITY_STATE_DIR', 'RUNNER_TRACKING_ID'].map(name => [name, process.env[name]])); -const toolProbe = stdin.includes('CAPTURE_SYNTHETIC_RG') ? spawnSync('rg', ['--version'], { encoding: 'utf8' }) : undefined; -if (toolProbe && toolProbe.status !== 0) throw toolProbe.error ?? new Error(toolProbe.stderr); -const pythonProbe = stdin.includes('CAPTURE_SYNTHETIC_PYTHON') ? spawnSync(process.env.PYTHON, ['-I', '-c', 'import json,os,sys; print(json.dumps([sys.prefix,os.environ.get("LD_LIBRARY_PATH")]))'], { encoding: 'utf8' }) : undefined; -if (pythonProbe && pythonProbe.status !== 0) throw new Error(pythonProbe.stderr || String(pythonProbe.error)); -const pythonRuntime = pythonProbe ? JSON.parse(pythonProbe.stdout) : undefined; -const knowledgePath = stdin.includes('synthetic worker configuration fixture') ? process.env.CODEX_SECURITY_KNOWLEDGE_BASE : undefined; -const knowledgeDocuments = knowledgePath === undefined ? undefined : Object.fromEntries(readdirSync(knowledgePath).map(name => [name, readFileSync(join(knowledgePath, name), 'utf8')])); -writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ ...(scopeInput === undefined ? {} : { scopeInput }), argv: process.argv.slice(2), stdin, cwd: process.cwd(), knowledgePath, knowledgeDocuments, codexHome: process.env.CODEX_HOME, cacheDirectory: process.env.XDG_CACHE_HOME, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), configPath: process.env.CODEX_SECURITY_CONFIG_PATH, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, python: process.env.PYTHON, mcpNodePath: process.env.CODEX_MCP_NODE_PATH, bundledTool: toolProbe?.stdout.trim(), pythonPrefix: pythonRuntime?.[0], pythonLibraryPath: pythonRuntime?.[1], runtimeEnvironment, providerKey: process.env.SYNTHETIC_GATEWAY_KEY, providerHeader: process.env.SYNTHETIC_HEADER_VALUE, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(profileContents === undefined ? {} : { profileContents }), ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) })); -if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => { if (!stdin.includes('IGNORE_TERMINATION')) setTimeout(() => process.exit(0), 100); }); - -if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); } -if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); } -if (stdin.includes('MCP_STARTUP_TIMEOUT') || stdin.includes('CATALOG_AUTH_ONLY') || stdin.includes('SYNC_AUTH_ONLY')) { - const syncWarning = stdin.includes('SYNC_AUTH_WARNING') || stdin.includes('SYNC_AUTH_ONLY'); - const bothWarnings = stdin.includes('BOTH_AUTH_WARNINGS'); - if (!syncWarning || bothWarnings) console.error('chatgpt authentication required for remote plugin catalog; api key auth is not supported'); - if (syncWarning || bothWarnings) console.error('chatgpt authentication required to sync remote plugins; api key auth is not supported'); - if (!stdin.includes('CATALOG_AUTH_ONLY') && !stdin.includes('SYNC_AUTH_ONLY')) { - const serverName = stdin.includes('OTHER_MCP_STARTUP_TIMEOUT') ? 'other_server' : stdin.includes('LEGACY_ARTIFACT_MCP_STARTUP_TIMEOUT') ? 'codex_security_artifacts' : 'cs_artifacts'; - const timeout = stdin.includes('REQUEST_TIMED_OUT') ? 'request timed out' : 'timed out handshaking with MCP server after 30s'; - console.error('required MCP servers failed to initialize: ' + serverName + ': ' + timeout); - } - if (stdin.includes('WITH_MISSING_API_KEY')) console.error('missing API key'); - if (stdin.includes('WITH_POLICY_REFUSAL')) console.error('Request blocked by cyberPolicy.'); - process.exit(1); -} -const resumeIndex = process.argv.indexOf('resume'); -const threadId = resumeIndex === -1 ? 'fixture-thread-id' : process.argv[resumeIndex + 1]; -console.log(JSON.stringify({ type: 'thread.started', thread_id: threadId })); -if (stdin.includes('IPC_DIAGNOSTIC_EVENT')) console.log(stdin.split('\\n')[1]); -if (process.env.FAKE_CODEX_ARTIFACT_EVENT) console.log(process.env.FAKE_CODEX_ARTIFACT_EVENT); -if (stdin.includes('MALFORMED_COMMAND_EVENT')) { - const output = JSON.parse(stdin.split('\\n')[1]); - const event = { type: 'item.completed', item: { id: 'fixture-command', type: 'command_execution', command: 'cat example.ts', aggregated_output: output, exit_code: 0, status: 'completed' } }; - console.log(JSON.stringify(event).slice(0, -1)); - process.exit(0); -} -const permissionProfileFallbackWarning = 'Configured value for \`permission_profile\` is disallowed by requirements; falling back from \`codex_security_deep_scan_worker\` to required value \`:read-only\`.'; -if (stdin.includes('PERMISSION_PROFILE_FALLBACK_ITEM')) console.log(JSON.stringify({ type: 'item.completed', item: { id: 'warning-1', type: 'error', message: permissionProfileFallbackWarning } })); -if (stdin.includes('PERMISSION_PROFILE_FALLBACK_EVENT')) console.log(JSON.stringify({ type: 'error', message: permissionProfileFallbackWarning })); -if (stdin.includes('BLOCK_AFTER_START')) await new Promise(() => {}); -if (stdin.includes('FAIL_THEN_HANG')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'fixture worker failed' } })); setInterval(() => {}, 1_000); await new Promise(() => {}); } -if (stdin.includes('RATE_LIMIT_CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: '429 Too Many Requests: Request blocked by cyberPolicy.' } })); process.exit(0); } -if (stdin.includes('CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by cyberPolicy.' } })); process.exit(0); } -if (stdin.includes('SAFETY_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by a safety policy violation.' } })); process.exit(0); } -if (stdin.includes('UPSTREAM_CYBERSECURITY_RISK_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This request has been flagged for possible cybersecurity risk.' } })); process.exit(0); } -if (stdin.includes('UPSTREAM_HIGH_RISK_CYBER_ACTIVITY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This request has been flagged for potentially high-risk cyber activity.' } })); process.exit(0); } -if (stdin.includes('CYBERSECURITY_RISK_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This content was flagged for possible cybersecurity risk.' } })); process.exit(0); } -if (stdin.includes('HIGH_RISK_CYBER_ACTIVITY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'This content was flagged for potentially high-risk cyber activity.' } })); process.exit(0); } -if (stdin.includes('RETRYABLE_STREAM_ERROR')) console.log(JSON.stringify({ type: 'error', message: 'Reconnecting... 2/5 (stream disconnected before completion: websocket closed by server before response.completed)' })); -if (stdin.includes('INCOMPLETE_STREAM')) { console.log(JSON.stringify({ type: 'error', message: 'fixture stream interrupted' })); process.exit(0); } -if (stdin.includes('BWRAP_NAMESPACE_FAILURE')) console.log(JSON.stringify({ type: 'item.completed', item: { id: 'command-1', type: 'command_execution', command: 'super-secret-command', aggregated_output: 'private source text\\nbwrap: Creating new namespace failed: nesting depth or /proc/sys/user/max_user_namespaces exceeded (ENOSPC)', exit_code: 1, status: 'failed' } })); -if (stdin.includes('ARTIFACT_TOOL_')) { - const server = stdin.includes('FOREIGN_ARTIFACT_TOOL_') ? 'untrusted_server' : stdin.includes('LEGACY_OWNED_ARTIFACT_TOOL_') ? 'codex_security_artifacts' : 'cs_artifacts'; - const tool = stdin.includes('ADDITIONAL_OWNED_ARTIFACT_TOOL_') ? 'additional_codex_security_worker_tool' : stdin.includes('DISCOVERY_OWNED_ARTIFACT_TOOL_') ? 'record_codex_security_discovery_candidates' : 'record_codex_security_deep_reduction'; - const item = { id: 'mcp-1', type: 'mcp_tool_call', server, tool, arguments: { secret: 'Bearer synthetic-secret', source: 'private source text' }, result: stdin.includes('REJECTED') ? { content: [{ type: 'text', text: 'private output sk-proj-synthetic-secret' }] } : null, error: stdin.includes('TRANSPORT_FAILED') ? { message: '--provider-error transport closed sk-proj-synthetic-secret /private/customer/path' } : null, status: 'failed' }; - console.log(JSON.stringify({ type: 'item.completed', item })); -} -console.log(JSON.stringify({ type: 'item.completed', item: { id: 'message-1', type: 'agent_message', text: 'fixture final response' } })); -console.log(JSON.stringify({ type: 'turn.completed', usage: stdin.includes('NULL_USAGE') ? null : { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } })); -if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await new Promise(() => {}); } -} -`, + fakeCodexSource( + preflightProfile, + preflightAllowed, + accountResult, + preflightMarkerPath, + ), ); await chmod(scriptPath, 0o755); process.env.FAKE_CODEX_MARKER = markerPath; diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 251d12fa8..9e0834b83 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -88,6 +88,7 @@ const distModules = [ "deduplication/review", "saved-scan", "saved-scan-bootstrap", + "scan-skill-paths", "server/embeddings", "server/errors", "server/sqlite-store", diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 137e5a74d..07de37d9e 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -32,6 +32,7 @@ import { } from "@openai/codex-sdk"; import { z } from "zod"; import { readThreatModelPath } from "./artifact-export.js"; +import { scanSkillConfig } from "./scan-skill-paths.js"; import { isRecord } from "./record.js"; import { createProfileCodex, @@ -3046,9 +3047,10 @@ export class CodexSecurity { ); } const runtime = this.#runtime; - const effectiveConfig = scanCyberAccessConfig( - requestedConfig, - options.cyberAccessProgram, + const effectiveConfig = await scanSkillConfig( + scanCyberAccessConfig(requestedConfig, options.cyberAccessProgram), + runtime.plugin, + runtime.codexHome, ); const approvalPolicy = scanApprovalPolicy(effectiveConfig); const preflightConfig = scanPreflightCodexConfig(effectiveConfig); @@ -4187,8 +4189,10 @@ function scanPrompt( const python = pluginPythonCommand(); const customValidation = discoveryPrompt !== undefined; return [ + "Use [$codex-security](mcp://codex-security) for this scan's artifact tools.", discoveryPrompt ?? - `Use the installed $codex-security:${skillName} skill at ${shellEnvironmentReference("CODEX_SECURITY_PLUGIN_ROOT", `/skills/${skillName}/SKILL.md`)}.`, + `Read ${shellEnvironmentReference("CODEX_SECURITY_PLUGIN_ROOT", `/skills/${skillName}/SKILL.md`)} and follow that ${skillName} workflow. Resolve all of its plugin-relative references and helpers from ${shellEnvironmentReference("CODEX_SECURITY_PLUGIN_ROOT")}. This is the readable copy of the selected plugin; its installed cache under CODEX_HOME is intentionally denied.`, + `For a Codex Security skill invoked by the assigned workflow, remove any codex-security: prefix from its name, read ${shellEnvironmentReference("CODEX_SECURITY_PLUGIN_ROOT", "/skills//SKILL.md")}, and follow that file. Resolve its relative references from its directory. Give delegated workers the same readable skill paths. Load only skills required by the assigned workflow.`, "Run this Codex Security scan non-interactively.", ...(modelProvider === "amazon-bedrock" ? [ @@ -4919,6 +4923,18 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { } const features = capabilityFeatures(source["features"]); if (Object.keys(features).length > 0) result["features"] = features; + const skills = source["skills"]; + if (isRecord(skills) && Array.isArray(skills["config"])) { + result["skills"] = { + config: skills["config"].flatMap((entry) => + isRecord(entry) && + safeString(entry["path"]) && + typeof entry["enabled"] === "boolean" + ? [{ path: entry["path"], enabled: entry["enabled"] }] + : [], + ), + }; + } const agents = source["agents"]; if (isRecord(agents)) { const sanitized: JsonObject = {}; @@ -5111,6 +5127,7 @@ function selectedWorkerRuntimeConfig( "model_instructions_file", "model_verbosity", "shell_environment_policy", + "skills", "web_search", "windows", ] diff --git a/sdk/typescript/src/scan-skill-paths.ts b/sdk/typescript/src/scan-skill-paths.ts new file mode 100644 index 000000000..3beae2f52 --- /dev/null +++ b/sdk/typescript/src/scan-skill-paths.ts @@ -0,0 +1,52 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { resolveCodexProfile, type JsonObject } from "./config.js"; +import { isRecord } from "./record.js"; +import { isWithin } from "./trusted-executable.js"; + +/** Keep native skill injection out of the denied credential home. The scan + * prompt loads the same workflow from CODEX_SECURITY_PLUGIN_ROOT instead. */ +export async function scanSkillConfig( + config: JsonObject, + plugin: { pluginRoot: string; installedRoot: string }, + credentialHome: string, +): Promise { + if (!isWithin(credentialHome, plugin.installedRoot)) return config; + const skills = await readdir(join(plugin.pluginRoot, "skills"), { + withFileTypes: true, + }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + const paths = new Set( + skills + .filter((entry) => entry.isDirectory()) + .map((entry) => + join(plugin.installedRoot, "skills", entry.name, "SKILL.md"), + ), + ); + if (paths.size === 0) return config; + const configured = resolveCodexProfile(config)["skills"]; + // Leave malformed user settings intact so native configuration reports them. + if (configured !== undefined && !isRecord(configured)) return config; + const entries = configured?.["config"]; + if (entries !== undefined && !Array.isArray(entries)) return config; + const selection = { + ...configured, + config: [ + ...(entries ?? []).filter( + (entry) => !isRecord(entry) || !paths.has(String(entry["path"])), + ), + ...Array.from(paths, (path) => ({ path, enabled: false })), + ], + } as JsonObject; + const updated = structuredClone(config); + updated["skills"] = selection; + const profiles = updated["profiles"]; + const profile = + typeof updated["profile"] === "string" && isRecord(profiles) + ? profiles[updated["profile"]] + : undefined; + if (isRecord(profile)) profile["skills"] = selection; + return updated; +} diff --git a/sdk/typescript/tests-ts/api-preflight-config.test.ts b/sdk/typescript/tests-ts/api-preflight-config.test.ts index 54f687c99..a6cc7178b 100644 --- a/sdk/typescript/tests-ts/api-preflight-config.test.ts +++ b/sdk/typescript/tests-ts/api-preflight-config.test.ts @@ -65,6 +65,53 @@ function runPreflight( } describe("CodexSecurity preflight configuration", () => { + test("retains only native skill path and enabled settings for fallback workers", () => { + const rootSkill = join( + tmpdir(), + "owned cache", + "security-scan", + "SKILL.md", + ); + const selectedSkill = join(tmpdir(), "other skill", "SKILL.md"); + const input: JsonObject = { + skills: { + config: [ + { path: rootSkill, enabled: false, token: "SYNTHETIC_ENTRY_SECRET" }, + ], + auth: "SYNTHETIC_ROOT_SECRET", + }, + profile: "selected", + profiles: { + selected: { + skills: { + config: [ + { + path: selectedSkill, + enabled: true, + extra: "SYNTHETIC_PROFILE_SECRET", + }, + ], + private: "SYNTHETIC_TABLE_SECRET", + }, + }, + }, + }; + const before = structuredClone(input); + expect(scanPreflightCodexConfig(input)).toEqual({ + skills: { config: [{ path: rootSkill, enabled: false }] }, + profile: "selected", + profiles: { + selected: { + skills: { config: [{ path: selectedSkill, enabled: true }] }, + }, + }, + }); + expect(input).toEqual(before); + expect(scanPreflightCodexConfig({ skills: { config: [] } })).toEqual({ + skills: { config: [] }, + }); + }); + test.each([ ["model", { model: null }, true], ["reasoning effort", { model_reasoning_effort: null }, true], diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index dcf2ab3a9..ef10358a6 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3093,7 +3093,13 @@ describe("CodexSecurity orchestration", () => { expect(createCodex.mock.lastCall?.[0]?.env).not.toHaveProperty( "Codex_Home", ); - expect(prompt).toContain("$codex-security:security-scan"); + expect(prompt).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + "/skills/security-scan/SKILL.md", + ), + ); + expect(prompt).not.toContain("$codex-security:"); expect(prompt).not.toContain("SDK-owned discovery workflow"); expect( createCodex.mock.lastCall?.[0]?.config?.["mcp_servers"], @@ -6252,6 +6258,208 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("isolates selected skill settings in generated snapshot-free Deep worker configs", async () => { + const root = await temporaryDirectory(); + const started = Promise.withResolvers(); + let starts = 0; + const paths = new Set(); + const clients = await Promise.all( + [0, 1].map(async (index) => { + const repository = join(root, `repository-${index}`); + const codexHome = join(root, `home-${index}`); + const scanDir = join(root, `scan-${index}`); + const configPath = join(root, `worker-config-${index}.toml`); + const installedRoot = join(codexHome, "plugins", "cache", "selected"); + const customSkill = { + path: join(root, `unrelated-${index}`, "SKILL.md"), + enabled: index === 0, + }; + await Promise.all( + [repository, codexHome, scanDir].map((directory) => + mkdir(directory, { mode: 0o700 }), + ), + ); + const config: JsonObject = { + profile: "selected", + profiles: { selected: { skills: { config: [customSkill] } } }, + }; + const client = new TestClient( + { codexOverrides: config }, + { + ...scanRuntimeDependencies(codexHome, scanDir), + prepareRuntime: runtimePreparer(codexHome, () => ({ + configPath, + plugin: { ...preparedRuntime(codexHome).plugin, installedRoot }, + })), + createCodex: (options) => ({ + startThread: () => ({ + id: null, + async runStreamed() { + if (++starts === 2) started.resolve(); + await started.promise; + expect( + options.env?.["CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"], + ).toBeUndefined(); + expect(options.env?.["CODEX_SECURITY_CONFIG_PATH"]).toBe( + configPath, + ); + paths.add(configPath); + const saved = resolveCodexProfile( + parseToml(await readFile(configPath, "utf8")) as JsonObject, + ); + const effective = resolveCodexProfile( + options.config as JsonObject, + ); + expect(saved["skills"]).toEqual(effective["skills"]); + const selections = (saved["skills"] as JsonObject)[ + "config" + ] as JsonObject[]; + expect(selections).toContainEqual(customSkill); + expect(selections).toContainEqual({ + path: join( + installedRoot, + "skills", + "security-scan", + "SKILL.md", + ), + enabled: false, + }); + expect(JSON.stringify(selections)).not.toContain( + `unrelated-${1 - index}`, + ); + throw new Error("snapshot-free skill configuration captured"); + }, + }), + }), + }, + ); + return { client, repository }; + }), + ); + try { + const results = await Promise.allSettled( + clients.map(({ client, repository }) => + client.run(repository, { mode: "deep" }).finally(started.resolve), + ), + ); + for (const result of results) + expect(result).toMatchObject({ + status: "rejected", + reason: expect.objectContaining({ + message: "snapshot-free skill configuration captured", + }), + }); + expect(starts).toBe(2); + expect(paths.size).toBe(2); + } finally { + started.resolve(); + await Promise.all(clients.map(({ client }) => client.close())); + } + }); + + test.each(["standard", "deep"] as const)( + "loads the readable workflow and disables only denied cached skills for %s scans", + async (mode) => { + const { root, repository, codexHome, scanDir } = await scanDirectories(); + const installedRoot = join( + codexHome, + "plugins", + "cache", + "codex-security", + "fixture-version", + ); + const unrelated = { + path: join(root, "other-skill", "SKILL.md"), + enabled: true, + }; + const deepScanConfigPath = join(codexHome, "deep-scan.toml"); + const codexOverrides: JsonObject = { + profile: "selected", + profiles: { selected: { skills: { config: [unrelated] } } }, + }; + const before = structuredClone(codexOverrides); + let reachedScan = false; + const client = new TestClient( + { codexOverrides }, + { + ...scanRuntimeDependencies(codexHome, scanDir), + prepareRuntime: runtimePreparer(codexHome, () => ({ + configPath: join(codexHome, "scan-config.toml"), + deepScanConfigPath, + plugin: { ...preparedRuntime(codexHome).plugin, installedRoot }, + })), + createCodex: (options) => ({ + startThread: () => ({ + id: null, + async runStreamed(prompt: string) { + reachedScan = true; + const workflow = + mode === "deep" ? "deep-security-scan" : "security-scan"; + expect(prompt).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + `/skills/${workflow}/SKILL.md`, + ), + ); + expect(prompt).not.toContain("$codex-security:"); + expect(prompt).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + "/skills//SKILL.md", + ), + ); + expect(options.env?.["CODEX_SECURITY_PLUGIN_ROOT"]).toBe( + PLUGIN_ROOT, + ); + const config = resolveCodexProfile( + options.config as JsonObject, + ); + const entries = (config["skills"] as JsonObject)[ + "config" + ] as JsonObject[]; + expect(entries).toContainEqual(unrelated); + for (const entry of await readdir(join(PLUGIN_ROOT, "skills"), { + withFileTypes: true, + })) { + if (entry.isDirectory()) + expect(entries).toContainEqual({ + path: join( + installedRoot, + "skills", + entry.name, + "SKILL.md", + ), + enabled: false, + }); + } + expect(config["mcp_servers"]).toBeUndefined(); + if (mode === "deep") { + const worker = parseToml( + await readFile( + options.env!["CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH"]!, + "utf8", + ), + )["worker_runtime"] as JsonObject; + expect(worker["skills"]).toEqual(config["skills"]); + } + throw new Error("readable workflow captured"); + }, + }), + }), + }, + ); + try { + await expect(client.run(repository, { mode })).rejects.toThrow( + "readable workflow captured", + ); + expect(reachedScan).toBe(true); + expect(codexOverrides).toEqual(before); + } finally { + await client.close(); + } + }, + ); + test("encodes paths and runtime values as data before sending the scan prompt", async () => { const root = await temporaryDirectory(); const injected = @@ -6716,7 +6924,19 @@ describe("CodexSecurity orchestration", () => { expect(prompt()).toContain( `Scan target: Git diff from ${revision} to ${revision}.`, ); - expect(prompt()).toContain("$codex-security:security-diff-scan"); + expect(prompt()).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + "/skills/security-diff-scan/SKILL.md", + ), + ); + expect(prompt()).not.toContain("$codex-security:"); + expect(prompt()).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + "/skills//SKILL.md", + ), + ); expect(prompt()).toContain("record_codex_security_scan_draft"); expect(prompt()).toContain("complete_codex_security_scan"); expect(prompt()).not.toContain("do not finalize or seal them"); @@ -6729,7 +6949,13 @@ describe("CodexSecurity orchestration", () => { await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( "prompt captured", ); - expect(prompt()).toContain("$codex-security:deep-security-scan"); + expect(prompt()).toContain( + shellEnvironmentReference( + "CODEX_SECURITY_PLUGIN_ROOT", + "/skills/deep-security-scan/SKILL.md", + ), + ); + expect(prompt()).not.toContain("$codex-security:"); expect(prompt()).not.toContain("record_codex_security_scan_draft"); expect(prompt()).toContain("complete_codex_security_scan"); expect(prompt()).not.toContain("do not finalize or seal them"); diff --git a/sdk/typescript/tests-ts/scan-mcp-startup.test.ts b/sdk/typescript/tests-ts/scan-mcp-startup.test.ts new file mode 100644 index 000000000..e25e13b7a --- /dev/null +++ b/sdk/typescript/tests-ts/scan-mcp-startup.test.ts @@ -0,0 +1,179 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Codex } from "@openai/codex-sdk"; +import { afterEach, expect, test } from "bun:test"; +import { DiffTarget } from "../src/index.js"; +import { resolveCodexCommand } from "../src/runtime.js"; +import { TestClient } from "./support/api-client.js"; +import { scanRuntimeDependencies } from "./support/api-events.js"; +import { createApiTestFixtures } from "./support/temporary-directories.js"; + +const { temporaryDirectory, cleanup } = + createApiTestFixtures("scan-mcp-startup-"); +afterEach(cleanup); + +test.each(["standard", "diff", "deep", "custom"] as const)( + "the %s scan prompt waits for its MCP tools before the first model request", + async (mode) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + for (const directory of [repository, codexHome, scanDir]) + await mkdir(directory, { mode: 0o700 }); + if (mode === "diff") { + await writeFile(join(repository, "fixture.ts"), "export {};\n"); + for (const args of [ + ["init", "-q"], + ["add", "fixture.ts"], + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "-qm", + "fixture", + ], + ]) + execFileSync("git", args, { cwd: repository, stdio: "pipe" }); + } + let prompt = ""; + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed(input: string) { + prompt = input; + throw new Error("scan prompt captured"); + }, + }), + }), + }); + try { + await expect( + client.run(repository, { + ...(mode === "deep" ? { mode: "deep" } : {}), + ...(mode === "diff" + ? { target: DiffTarget.refs({ base: "HEAD", head: "HEAD" }) } + : {}), + ...(mode === "custom" + ? { validationPrompt: "Validate the synthetic candidates." } + : {}), + }), + ).rejects.toThrow("scan prompt captured"); + } finally { + await client.close(); + } + + const captured = Promise.withResolvers>(); + let initialized = false; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (new URL(request.url).pathname === "/mcp") { + if (request.method !== "POST") + return new Response(null, { status: 405 }); + const message = (await request.json()) as { + id?: number; + method: string; + params?: { protocolVersion?: string }; + }; + if (message.method === "initialize") { + // Keep a healthy server pending beyond the fixture's optional grace. + // Assert catalog contents, not wall-clock timing. + await Bun.sleep(250); + initialized = true; + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: + message.params?.protocolVersion ?? "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "synthetic-startup", version: "1" }, + }, + }); + } + if (message.method === "tools/list") + return Response.json({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "synthetic_ready", + description: "Synthetic startup readiness tool.", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }); + return new Response(null, { status: 202 }); + } + if (new URL(request.url).pathname !== "/v1/responses") + return Response.json({}); + const body = (await request.json()) as Record; + captured.resolve({ body, initialized }); + return new Response( + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"synthetic-response","output":[],"usage":{"input_tokens":0,"output_tokens":0}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const nativeHome = join(root, "native-home"); + await mkdir(nativeHome, { mode: 0o700 }); + const environment: Record = { CODEX_HOME: nativeHome }; + for (const key of ["PATH", "SystemRoot", "TEMP", "TMP"]) + if (process.env[key]) environment[key] = process.env[key]!; + const codex = new Codex({ + codexPathOverride: resolveCodexCommand({}).command, + env: environment, + config: { + model_provider: "synthetic", + model_providers: { + synthetic: { + name: "Synthetic", + base_url: `${server.url}v1`, + wire_api: "responses", + requires_openai_auth: false, + }, + }, + mcp_optional_startup_grace_ms: 1, + mcp_servers: { + "codex-security": { url: `${server.url}mcp`, startup_timeout_sec: 5 }, + }, + }, + }); + const abort = new AbortController(); + const execution = codex + .startThread({ + workingDirectory: repository, + skipGitRepoCheck: true, + model: "gpt-5.4", + approvalPolicy: "never", + sandboxMode: "read-only", + }) + .run(prompt, { signal: abort.signal }) + .catch((error: unknown) => error); + try { + const request = await Promise.race([ + captured.promise, + execution.then((result) => { + throw new Error( + `Native turn ended before the synthetic request: ${String(result)}`, + ); + }), + ]); + expect(request["initialized"]).toBe(true); + expect(JSON.stringify(request["body"])).toContain("synthetic_ready"); + } finally { + abort.abort(); + await execution; + await server.stop(true); + } + }, +); diff --git a/sdk/typescript/tests-ts/scan-skill-paths.test.ts b/sdk/typescript/tests-ts/scan-skill-paths.test.ts new file mode 100644 index 000000000..7893d803c --- /dev/null +++ b/sdk/typescript/tests-ts/scan-skill-paths.test.ts @@ -0,0 +1,162 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { resolveCodexProfile, type JsonObject } from "../src/config.js"; +import { scanSkillConfig } from "../src/scan-skill-paths.js"; +import { createApiTestFixtures } from "./support/temporary-directories.js"; + +const { temporaryDirectory, cleanup } = + createApiTestFixtures("scan-skill-paths-"); +afterEach(cleanup); + +function selection(config: JsonObject) { + const entries = (config["skills"] as JsonObject)["config"] as JsonObject[]; + return [...entries].sort((a, b) => + String(a["path"]).localeCompare(String(b["path"])), + ); +} + +async function fixture() { + const root = await temporaryDirectory(); + const home = join(root, "credential home [fixture]"); + const pluginRoot = join(root, "readable plugin"); + const installedRoot = join(home, "plugins", "cache", "selected", "1.0.0"); + for (const name of ["security-scan", "deep-security-scan"]) { + await mkdir(join(pluginRoot, "skills", name), { recursive: true }); + await writeFile( + join(pluginRoot, "skills", name, "SKILL.md"), + "Synthetic workflow.\n", + ); + } + await writeFile( + join(pluginRoot, "skills", "README.md"), + "Not a skill directory.\n", + ); + const blocked = ["deep-security-scan", "security-scan"].map((name) => ({ + path: join(installedRoot, "skills", name, "SKILL.md"), + enabled: false, + })); + return { root, home, plugin: { pluginRoot, installedRoot }, blocked }; +} + +test.each([false, true])( + "disables cached skill files while preserving effective selection (profile=%s)", + async (profile) => { + const { root, home, plugin, blocked } = await fixture(); + const unrelated = { + path: join(root, "other-plugin", "SKILL.md"), + enabled: true, + }; + const previouslyDisabled = { + path: join(root, "disabled", "SKILL.md"), + enabled: false, + }; + const selected: JsonObject = { + config: [ + unrelated, + previouslyDisabled, + { ...blocked[0]!, enabled: true }, + ], + }; + const config: JsonObject = { + plugins: { "selected@local": { enabled: true } }, + skills: profile + ? { + config: [ + { path: join(root, "overridden", "SKILL.md"), enabled: true }, + ], + } + : selected, + ...(profile + ? { + profile: "active", + profiles: { + active: { skills: selected }, + unused: { skills: { config: [unrelated] } }, + }, + } + : {}), + }; + const before = structuredClone(config); + const actual = await scanSkillConfig(config, plugin, home); + expect(config).toEqual(before); + expect(actual).not.toBe(config); + expect(selection(resolveCodexProfile(actual))).toEqual( + selection({ + skills: { config: [unrelated, previouslyDisabled, ...blocked] }, + }), + ); + expect(actual["plugins"]).toEqual(config["plugins"]); + if (profile) { + expect((actual["profiles"] as JsonObject)["unused"]).toEqual( + (before["profiles"] as JsonObject)["unused"], + ); + } + }, +); + +test("leaves plugins outside the credential home and absent skills unchanged", async () => { + const { root, home, plugin } = await fixture(); + const config: JsonObject = { + skills: { + config: [{ path: join(root, "custom", "SKILL.md"), enabled: true }], + }, + }; + for (const installedRoot of [join(root, "installed"), home + "-sibling"]) { + expect( + await scanSkillConfig(config, { ...plugin, installedRoot }, home), + ).toBe(config); + } + expect( + await scanSkillConfig( + config, + { ...plugin, pluginRoot: join(root, "missing-skills") }, + home, + ), + ).toBe(config); +}); + +test.each([false, true])( + "preserves malformed native skill settings (profile=%s)", + async (profile) => { + const { home, plugin } = await fixture(); + for (const skills of [ + false, + "invalid", + [], + { config: false }, + { config: "invalid" }, + { config: {} }, + ]) { + const config: JsonObject = profile + ? { profile: "selected", profiles: { selected: { skills } } } + : { skills }; + const before = structuredClone(config); + expect(await scanSkillConfig(config, plugin, home)).toBe(config); + expect(config).toEqual(before); + } + }, +); + +test("isolates concurrent scan selections without changing their shared configuration", async () => { + const [first, second] = await Promise.all([fixture(), fixture()]); + const config: JsonObject = { + plugins: { "selected@local": { enabled: true } }, + }; + const [a, b] = await Promise.all([ + scanSkillConfig(config, first.plugin, first.home), + scanSkillConfig(config, second.plugin, second.home), + ]); + expect(selection(a)).toEqual( + selection({ skills: { config: first.blocked } }), + ); + expect(selection(b)).toEqual( + selection({ skills: { config: second.blocked } }), + ); + expect(a["skills"]).not.toBe(b["skills"]); + expect(config).toEqual({ plugins: { "selected@local": { enabled: true } } }); + (a["skills"] as JsonObject)["config"] = []; + expect(selection(b)).toEqual( + selection({ skills: { config: second.blocked } }), + ); +});