From 41ae14016ad906f4f7bcfa185905e9453427c13e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 20:55:16 +0000 Subject: [PATCH 01/13] fix(deep-scan): preserve tool settings across coordinator and follow-up turns --- sdk/typescript/src/api.ts | 110 +++++++++++------- sdk/typescript/src/deep-scan.ts | 56 +++++++++ .../api-deep-scan-runtime-routing.test.ts | 66 ++++++++--- .../tests-ts/api-direct-deep-scan.test.ts | 30 ++++- sdk/typescript/tests-ts/support/api-client.ts | 3 + 5 files changed, 206 insertions(+), 59 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 32b4b9cad8..f83a284f95 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -104,7 +104,11 @@ import { import type { ScanTokenUsage } from "./cost-model.js"; import { ScanProgressTracker, type DeepScanProgress } from "./scan-progress.js"; import { findScanSession } from "./scan-logs.js"; -import { DIRECT_DEEP_SCAN_CONTROL_USAGE, runDeepScan } from "./deep-scan.js"; +import { + DIRECT_DEEP_SCAN_CONTROL_USAGE, + readDeepScanParentConfig, + runDeepScan, +} from "./deep-scan.js"; import { deepScanOptions, resolveDeepScanConfig, @@ -675,6 +679,7 @@ interface ClientDependencies { runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindingsInternal; runDeepScan?: typeof runDeepScan; + readDeepScanParentConfig?: typeof readDeepScanParentConfig; canRunDirectDeepScan?: () => boolean; } @@ -2856,10 +2861,36 @@ export class CodexSecurity { runtimePaths, options.auth, git, - mode === "deep" && !directDeepScan - ? deepScanParentConfig(session.sessionConfig) - : undefined, ); + let parentCodex = codex; + if ( + mode === "deep" && + !directDeepScan && + !this.#dependencies.hostedScan + ) { + const parentConfig = await ( + this.#dependencies.readDeepScanParentConfig ?? + readDeepScanParentConfig + )({ + codexOptions, + preflightCommand: + await this.#providerPreflightCommand(effectiveConfig), + pluginRoot: runtime.plugin.pluginRoot, + scanDir, + signal, + }); + const override = deepScanParentToolOverride(parentConfig); + if (override !== undefined) { + // Only the coordinator turn needs to keep its MCP call outside a yielding cell. + parentCodex = await this.#dependencies.createCodex({ + ...codexOptions, + configOverrides: [ + ...(codexOptions.configOverrides ?? []), + override, + ], + }); + } + } const threadOptions: ThreadOptions = { threadSource: CODEX_SECURITY_THREAD_SOURCES.scan, workingDirectory: scanDir, @@ -2868,18 +2899,18 @@ export class CodexSecurity { }; let thread: CodexThreadLike; if (typeof resumeThreadId === "string") { - if (codex.resumeThread === undefined) { + if (parentCodex.resumeThread === undefined) { throw new CodexSecurityError( "The configured Codex client does not support resuming sessions.", ); } - thread = codex.resumeThread(resumeThreadId, threadOptions); + thread = parentCodex.resumeThread(resumeThreadId, threadOptions); tracker.start(resumeThreadId); if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; await tracker.refresh().catch(reportTrackingError); checkOpen(); } else { - thread = codex.startThread(threadOptions); + thread = parentCodex.startThread(threadOptions); } const serializedPaths = normalized.kind === "paths" ? jsonForPrompt(normalized.paths) : null; @@ -2898,8 +2929,25 @@ export class CodexSecurity { cyberAccessProgram: options.cyberAccessProgram, }; const postScanPrompt = options.postScanPrompt; + let scanThreadId = thread.id; if (postScanPrompt?.trim()) { - runPostScan = () => thread.runStreamed(postScanPrompt, turnOptions); + runPostScan = () => { + if ( + (parentCodex !== codex || directDeepScan) && + scanThreadId !== null + ) { + if (!codex.resumeThread) + throw new CodexSecurityError( + "The configured Codex client does not support resuming sessions.", + ); + return codex + .resumeThread(scanThreadId, threadOptions) + .runStreamed(postScanPrompt, turnOptions); + } + const followUp = + parentCodex === codex ? thread : codex.startThread(threadOptions); + return followUp.runStreamed(postScanPrompt, turnOptions); + }; } if (mode === "standard" && options.maxTimeMinutes !== undefined) { const timePolicy = await readScanTimeBudgetPolicy( @@ -2953,17 +3001,7 @@ export class CodexSecurity { "Codex did not resume the original scan session.", ); } - if (directDeepScan && postScanPrompt?.trim()) { - runPostScan = () => { - if (codex.resumeThread === undefined) - throw new CodexSecurityError( - "The configured Codex client does not support resuming sessions.", - ); - return codex - .resumeThread(threadId, threadOptions) - .runStreamed(postScanPrompt, turnOptions); - }; - } + scanThreadId = threadId; if (resumeThreadId !== undefined) { return; } @@ -5813,30 +5851,18 @@ export function classifyConnectionFailure( return "unknown"; } -function deepScanParentConfig(config: JsonObject): JsonObject { - const resolved = resolveCodexProfile(config); - const features = resolved["features"] ?? {}; - if (!isRecord(features)) return resolved; +function deepScanParentToolOverride(config: JsonObject): string | undefined { + const features = config["features"] ?? {}; + if (!isRecord(features)) return undefined; const mode = features["code_mode"]; - const settings = typeof mode === "boolean" ? { enabled: mode } : (mode ?? {}); - // Leave malformed user settings for the native config parser to reject. - if (!isRecord(settings)) return resolved; - const direct = settings["direct_only_tool_namespaces"] ?? []; - if (!Array.isArray(direct)) return resolved; - // The native parent waits for direct tool calls before it can end its turn. - // A code-mode cell can yield the still-running coordinator to the model. - return { - ...resolved, - features: { - ...features, - code_mode: { - ...settings, - direct_only_tool_namespaces: [ - ...new Set([...direct, "mcp__codex_security", "codex_security"]), - ], - }, - }, - }; + if (mode !== undefined && typeof mode !== "boolean" && !isRecord(mode)) + return undefined; + const direct = + (isRecord(mode) ? mode["direct_only_tool_namespaces"] : undefined) ?? []; + if (!Array.isArray(direct)) return undefined; + const required = ["mcp__codex_security", "codex_security"]; + if (required.every((name) => direct.includes(name))) return undefined; + return `features.code_mode.direct_only_tool_namespaces=${inlineToml([...new Set([...direct, ...required])])}`; } export function scanRuntimeCodexConfig( diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index e81f1079a9..cdc79eb0b8 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -4,6 +4,8 @@ import { pathToFileURL } from "node:url"; import type { CodexOptions } from "@openai/codex-sdk"; import { bundledCodexSdkEnvironment } from "./codex-sdk-environment.js"; import { CodexSecurityError } from "./errors.js"; +import { inlineToml, type JsonObject } from "./config.js"; +import { isRecord } from "./record.js"; import { executablePathForSpawn, type CodexCommand } from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { withCodexPreflightLock } from "./provider-profile.js"; @@ -19,6 +21,60 @@ export interface DirectDeepScanOptions { signal: AbortSignal; } +// The stock SDK emits one flag per leaf, including literal dotted keys in order. +function sdkConfigFlags(config: JsonObject, prefix = ""): string[] { + return Object.entries(config).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return isRecord(value) && Object.keys(value).length + ? sdkConfigFlags(value, path) + : [`${path}=${inlineToml(value)}`]; + }); +} + +/** Read the same native layers and CLI overrides as the parent before adding its tool. */ +export async function readDeepScanParentConfig( + options: Pick< + DirectDeepScanOptions, + "codexOptions" | "preflightCommand" | "pluginRoot" | "scanDir" | "signal" + >, +): Promise { + const { codexOptions, preflightCommand: command } = options; + const native = await import( + pathToFileURL( + join(options.pluginRoot, "mcp", "permission-profile-preflight.mjs"), + ).href + ); + const config = codexOptions.config ?? {}; + // The provider-profile launcher emits whole top-level tables instead. + const overrides = + codexOptions.nativeProfile === undefined + ? sdkConfigFlags(config) + : ( + await import( + pathToFileURL( + join(options.pluginRoot, "scripts", "codex_profile.mjs"), + ).href + ) + ).profileConfigOverrides(config); + const environment = bundledCodexSdkEnvironment(command.command, { + ...codexOptions.env, + ...(codexOptions.apiKey === undefined + ? {} + : { CODEX_API_KEY: codexOptions.apiKey }), + }); + return withCodexPreflightLock(environment, options.signal, () => + native.readDeepScanRuntimeConfig({ + codexPath: command.command, + commandArgs: command.args, + cwd: options.scanDir, + configOverrides: [...overrides, ...(codexOptions.configOverrides ?? [])], + env: environment, + signal: options.signal, + context: "helper", + }), + ); +} + // The direct coordinator records context but never calls a model itself. export const DIRECT_DEEP_SCAN_CONTROL_USAGE = { input_tokens: 0, diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index fb95d691b9..b8c2c87762 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -1,8 +1,13 @@ -import { mkdir } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, mock, test } from "bun:test"; import type { CodexOptions } from "@openai/codex-sdk"; -import type { DirectDeepScanOptions } from "../src/deep-scan.js"; +import { + readDeepScanParentConfig, + type DirectDeepScanOptions, +} from "../src/deep-scan.js"; +import { resolveCodexCommand } from "../src/runtime.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; import { TestClient } from "./support/api-client.js"; import { preparedRuntime, @@ -78,6 +83,7 @@ test.each([ ); throw new Error("direct route reached"); }); + const started: CodexOptions[] = []; const createCodex = mock((options: CodexOptions) => { expect(options.apiKey).toBe("synthetic-routing-key"); expect(options.env?.["CODEX_MCP_NODE_PATH"]).toBe(selectedNode); @@ -87,20 +93,14 @@ test.each([ model_reasoning_effort: "high", features: { shell_tool: false, - code_mode: - typeof codeMode === "string" || scenario.route === "direct" - ? codeMode - : { - direct_only_tool_namespaces: [ - "existing_tools", - "mcp__codex_security", - "codex_security", - ], - }, + code_mode: codeMode, }, }); return { - startThread: () => ({ id: null, runStreamed: parentTurn }), + startThread: () => { + started.push(options); + return { id: null, runStreamed: parentTurn }; + }, }; }); const client = new TestClient( @@ -139,7 +139,17 @@ test.each([ await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( `${scenario.route} route reached`, ); - expect(createCodex).toHaveBeenCalledTimes(1); + expect(started).toHaveLength(1); + const overrides = started[0]!.configOverrides?.filter((value) => + value.startsWith("features.code_mode.direct_only_tool_namespaces="), + ); + expect(overrides).toEqual( + scenario.route === "parent" && typeof codeMode !== "string" + ? [ + 'features.code_mode.direct_only_tool_namespaces=["existing_tools","mcp__codex_security","codex_security"]', + ] + : [], + ); expect(parentTurn).toHaveBeenCalledTimes( scenario.route === "parent" ? 1 : 0, ); @@ -151,3 +161,31 @@ test.each([ await client.close(); } }); + +test("parent configuration reads inherited namespaces and preserves dotted SDK override order", async () => { + const home = await temporaryDirectory(); + const scanDir = await temporaryDirectory(); + await writeFile( + join(home, "config.toml"), + '[features.code_mode]\ndirect_only_tool_namespaces = ["inherited_tools"]\n', + ); + const read = (config: CodexOptions["config"]) => + readDeepScanParentConfig({ + codexOptions: { config, env: { CODEX_HOME: home } }, + preflightCommand: resolveCodexCommand({}), + pluginRoot: PLUGIN_ROOT, + scanDir, + signal: new AbortController().signal, + }); + const expected = (name: string) => ({ + features: { code_mode: { direct_only_tool_namespaces: [name] } }, + }); + expect(await read({})).toMatchObject(expected("inherited_tools")); + expect( + await read({ + features: { "code_mode.direct_only_tool_namespaces": ["first"] }, + "features.code_mode.direct_only_tool_namespaces": ["last"], + }), + ).toMatchObject(expected("last")); + await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow(); +}); diff --git a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts index 5076368d20..9da22ea6e4 100644 --- a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts +++ b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts @@ -207,15 +207,26 @@ test.each(["configured", "managed"] as const)( ...runtimeEnvironment, CODEX_API_KEY: "synthetic-bun-scan-key", }; + const postScanPrompt = "Read the completed scan in a code-mode cell."; + const followUp = mock(async (prompt: string) => { + expect(prompt).toBe(postScanPrompt); + return { events: completedEvents("owner") }; + }); + const codeMode = { direct_only_tool_namespaces: ["caller_tools"] }; const parentTurn = mock(async (prompt: string) => { expect(prompt).toContain("start_codex_security_deep_scan"); - throw new Error("parent route reached"); + async function* events() { + yield { type: "thread.started", thread_id: "owner" }; + throw new Error("parent route reached"); + } + return { events: events() }; }); const client = new TestClient( { codexOverrides: { model: "gpt-6.1-sol", model_reasoning_effort: "high", + features: { code_mode: codeMode }, }, }, { @@ -238,10 +249,21 @@ test.each(["configured", "managed"] as const)( model_reasoning_effort: "high", }); return { - startThread: (options) => { - expect(options.workingDirectory).toBe(scanDir); + startThread: (threadOptions) => { + expect(threadOptions.workingDirectory).toBe(scanDir); return { id: null, runStreamed: parentTurn }; }, + resumeThread: (id, threadOptions) => { + expect(id).toBe("owner"); + expect(threadOptions.workingDirectory).toBe(scanDir); + expect(options.config?.["features"]).toMatchObject({ + code_mode: codeMode, + }); + expect(options.configOverrides?.join("\n")).not.toContain( + "direct_only_tool_namespaces", + ); + return { id, runStreamed: followUp }; + }, }; }, runDeepScan: async function* () { @@ -253,9 +275,11 @@ test.each(["configured", "managed"] as const)( await expect( client.run(repository, { mode: "deep", + postScanPrompt, }), ).rejects.toThrow("parent route reached"); expect(parentTurn).toHaveBeenCalledTimes(1); + expect(followUp).toHaveBeenCalledTimes(1); } finally { await client.close(); } diff --git a/sdk/typescript/tests-ts/support/api-client.ts b/sdk/typescript/tests-ts/support/api-client.ts index 1d78e1d4d4..5bd30fcbc5 100644 --- a/sdk/typescript/tests-ts/support/api-client.ts +++ b/sdk/typescript/tests-ts/support/api-client.ts @@ -54,6 +54,9 @@ export function withSyntheticScanEvents>( }; }, runDeepScan: dependencies.runDeepScan ?? runDeepScan, + readDeepScanParentConfig: + dependencies.readDeepScanParentConfig ?? + (async ({ codexOptions }) => codexOptions.config ?? {}), }; } From ddebf4dbaf1c1c75dd0fff904cd8b67902e03016 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 21:23:45 +0000 Subject: [PATCH 02/13] fix(deep-scan): respect the selected MCP server and its caller policy --- sdk/typescript/src/api.ts | 81 ++++---- sdk/typescript/src/deep-scan.ts | 78 ++++++++ .../api-deep-scan-runtime-routing.test.ts | 180 +++++++++++++++++- sdk/typescript/tests-ts/api.test.ts | 1 + .../tests-ts/component-scan.test.ts | 5 + 5 files changed, 305 insertions(+), 40 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index f83a284f95..9a44b0cd87 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -106,6 +106,7 @@ import { ScanProgressTracker, type DeepScanProgress } from "./scan-progress.js"; import { findScanSession } from "./scan-logs.js"; import { DIRECT_DEEP_SCAN_CONTROL_USAGE, + deepScanParentToolOverrides, readDeepScanParentConfig, runDeepScan, } from "./deep-scan.js"; @@ -309,6 +310,7 @@ interface PreparedRuntime { } interface PreparedSession { + deepScanPluginUsesDefaultMcpFile?: boolean; safetyIdentifier?: string; runtime: PreparedRuntime; runtimeHome: string; @@ -2866,7 +2868,8 @@ export class CodexSecurity { if ( mode === "deep" && !directDeepScan && - !this.#dependencies.hostedScan + !this.#dependencies.hostedScan && + session.deepScanPluginUsesDefaultMcpFile ) { const parentConfig = await ( this.#dependencies.readDeepScanParentConfig ?? @@ -2879,14 +2882,20 @@ export class CodexSecurity { scanDir, signal, }); - const override = deepScanParentToolOverride(parentConfig); - if (override !== undefined) { + const pluginServers = await readDeepScanPluginServers( + runtime.plugin.installedRoot, + ); + const overrides = + pluginServers === undefined + ? [] + : deepScanParentToolOverrides(parentConfig, pluginServers); + if (overrides.length > 0) { // Only the coordinator turn needs to keep its MCP call outside a yielding cell. parentCodex = await this.#dependencies.createCodex({ ...codexOptions, configOverrides: [ ...(codexOptions.configOverrides ?? []), - override, + ...overrides, ], }); } @@ -4119,7 +4128,9 @@ export class CodexSecurity { ); } const runtime = this.#runtime; - if (deepScan) await requireCurrentDeepScanPlugin(runtime); + const mcpSource = deepScan + ? (await requireCurrentDeepScanPlugin(runtime))["mcpServers"] + : null; const effectiveConfig = await scanSkillConfig( scanCyberAccessConfig(requestedConfig, options.cyberAccessProgram), runtime.plugin, @@ -4237,6 +4248,11 @@ export class CodexSecurity { checkOpen(); return { runtime, + // Leave other plugin declarations under the caller's native routing policy. + deepScanPluginUsesDefaultMcpFile: + mcpSource === undefined || + mcpSource === "./.mcp.json" || + mcpSource === ".mcp.json", safetyIdentifier: options.safetyIdentifier, runtimeHome, effectiveConfig, @@ -5851,20 +5867,6 @@ export function classifyConnectionFailure( return "unknown"; } -function deepScanParentToolOverride(config: JsonObject): string | undefined { - const features = config["features"] ?? {}; - if (!isRecord(features)) return undefined; - const mode = features["code_mode"]; - if (mode !== undefined && typeof mode !== "boolean" && !isRecord(mode)) - return undefined; - const direct = - (isRecord(mode) ? mode["direct_only_tool_namespaces"] : undefined) ?? []; - if (!Array.isArray(direct)) return undefined; - const required = ["mcp__codex_security", "codex_security"]; - if (required.every((name) => direct.includes(name))) return undefined; - return `features.code_mode.direct_only_tool_namespaces=${inlineToml([...new Set([...direct, ...required])])}`; -} - export function scanRuntimeCodexConfig( config: JsonObject, protectedCredentialHome?: string, @@ -6176,26 +6178,25 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { async function requireCurrentDeepScanPlugin( runtime: PreparedRuntime, -): Promise { +): Promise> { const manifest: unknown = JSON.parse( await readFile( join(runtime.plugin.pluginRoot, ".codex-plugin", "plugin.json"), "utf8", ), ); - const capabilities = isRecord(manifest) - ? manifest["codexSecurity"] - : undefined; if ( runtime.deepScanConfigPath === undefined || - !isRecord(capabilities) || - capabilities["directDeepScanEngine"] !== 1 || - capabilities["workerProviderSnapshot"] !== 4 + !isRecord(manifest) || + !isRecord(manifest["codexSecurity"]) || + manifest["codexSecurity"]["directDeepScanEngine"] !== 1 || + manifest["codexSecurity"]["workerProviderSnapshot"] !== 4 ) { throw new CodexSecurityError( "Deep Scan requires the current plugin's direct engine and isolated worker runtime support. Update the custom plugin or use the bundled plugin.", ); } + return manifest; } function selectedWorkerRuntimeConfig( @@ -6256,23 +6257,29 @@ function selectedWorkerRuntimeConfig( }; } -async function pluginSupportsIsolatedDeepScanConfig( +async function readDeepScanPluginServers( pluginRoot: string, -): Promise { - let configuration: unknown; +): Promise | undefined> { try { - configuration = JSON.parse( + const configuration: unknown = JSON.parse( await readFile(join(pluginRoot, ".mcp.json"), "utf8"), ); + const servers = isRecord(configuration) + ? configuration["mcpServers"] + : undefined; + return isRecord(servers) ? servers : undefined; } catch { - return false; + return undefined; } - if (!isRecord(configuration)) return false; - const servers = configuration["mcpServers"]; - if (!isRecord(servers)) return false; - const server = servers["codex-security"]; - if (!isRecord(server)) return false; - const environment = server["env_vars"]; +} + +async function pluginSupportsIsolatedDeepScanConfig( + pluginRoot: string, +): Promise { + const server = (await readDeepScanPluginServers(pluginRoot))?.[ + "codex-security" + ]; + const environment = isRecord(server) ? server["env_vars"] : undefined; return ( Array.isArray(environment) && environment.includes(DEEP_SCAN_CONFIG_PATH_ENVIRONMENT) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index cdc79eb0b8..1e963fd74d 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -75,6 +75,84 @@ export async function readDeepScanParentConfig( ); } +/** Keep direct dispatch scoped to the original SDK coordinator turn and MCP server. */ +export function deepScanParentToolOverrides( + config: JsonObject, + pluginServers: Record, +): string[] { + const features = config["features"] ?? {}; + if (!isRecord(features)) return []; + const mode = features["code_mode"]; + if (mode !== undefined && typeof mode !== "boolean" && !isRecord(mode)) + return []; + const direct = + (isRecord(mode) ? mode["direct_only_tool_namespaces"] : undefined) ?? []; + if (!Array.isArray(direct)) return []; + + const owner = "codex-security"; + const bare = "codex_security"; + const prefixed = "mcp__codex_security"; + let required = [prefixed, bare]; + const overrides: string[] = []; + const configured = config["mcp_servers"]; + // Native registrations take precedence over the selected plugin's entries. + const servers: Record = { + ...pluginServers, + ...(isRecord(configured) ? configured : {}), + }; + const ownerConfig = servers[owner]; + if (!isRecord(ownerConfig) || ownerConfig["enabled"] === false) return []; + const omitted = ownerConfig["omit_tools_from"]; + if (Array.isArray(omitted) && omitted.includes("direct")) return []; + const option = features["non_prefixed_mcp_tool_names"]; + const enabled = + option === true || (isRecord(option) && option["enabled"] === true); + const selected = isRecord(option) ? option["server_names"] : undefined; + function namespace(server: string): string { + const name = server.replace(/[-.:@/]/gu, "_"); + const omitPrefix = + enabled && (!Array.isArray(selected) || selected.includes(server)); + return omitPrefix || name.startsWith("mcp__") ? name : `mcp__${name}`; + } + const occupied = new Set( + Object.entries(servers) + .filter( + ([server, settings]) => + server !== owner && + isRecord(settings) && + settings["enabled"] !== false, + ) + .map(([server]) => namespace(server)), + ); + const current = namespace(owner); + if (occupied.has(current)) { + // Move only this raw server into a free namespace. Preserve an explicit + // naming policy and avoid making the other server inherit a direct slot. + if ( + current === bare || + occupied.has(bare) || + direct.includes(current) || + (option != null && !enabled) + ) + return []; + overrides.push( + `features.non_prefixed_mcp_tool_names=${inlineToml({ + enabled: true, + server_names: [...(Array.isArray(selected) ? selected : []), owner], + })}`, + ); + required = [bare]; + } else if (occupied.has(prefixed) || occupied.has(bare)) { + required = [current]; + } + if (!required.every((name) => direct.includes(name))) { + overrides.push( + `features.code_mode.direct_only_tool_namespaces=${inlineToml([...new Set([...direct, ...required])])}`, + ); + } + return overrides; +} + // The direct coordinator records context but never calls a model itself. export const DIRECT_DEEP_SCAN_CONTROL_USAGE = { input_tokens: 0, diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index b8c2c87762..8813cd677b 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -1,8 +1,11 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, mock, test } from "bun:test"; import type { CodexOptions } from "@openai/codex-sdk"; +import { parse } from "smol-toml"; +import type { JsonObject, JsonValue } from "../src/config.js"; import { + deepScanParentToolOverrides, readDeepScanParentConfig, type DirectDeepScanOptions, } from "../src/deep-scan.js"; @@ -18,6 +21,102 @@ import { createApiTestFixtures } from "./support/temporary-directories.js"; const { temporaryDirectory, cleanup } = createApiTestFixtures(); afterEach(cleanup); +test("a colliding MCP server leaves only the original coordinator's namespace direct", () => { + const owner = "codex-security"; + const alias = "codex.security"; + const bare = "codex_security"; + const prefixed = "mcp__codex_security"; + const servers = { [owner]: { enabled: true }, [alias]: { enabled: true } }; + const selected = (...names: string[]) => ({ + enabled: true, + server_names: names, + }); + const direct = (...names: string[]) => ({ + direct_only_tool_namespaces: names, + }); + const overrides = ( + prefix?: JsonValue, + existing: string[] = ["audit"], + registrations: JsonObject = servers, + ) => { + const config = { + mcp_servers: registrations, + features: { + ...(prefix === undefined + ? {} + : { non_prefixed_mcp_tool_names: prefix }), + code_mode: direct(...existing), + }, + }; + return parse(deepScanParentToolOverrides(config, {}).join("\n"))[ + "features" + ]; + }; + + expect(overrides()).toEqual({ + non_prefixed_mcp_tool_names: selected(owner), + code_mode: direct("audit", bare), + }); + expect(overrides(selected("retained-tools"))).toEqual({ + non_prefixed_mcp_tool_names: selected("retained-tools", owner), + code_mode: direct("audit", bare), + }); + expect(overrides(selected(alias))).toEqual({ + code_mode: direct("audit", prefixed), + }); + expect(overrides(selected(owner))).toEqual({ + code_mode: direct("audit", bare), + }); + expect( + overrides(undefined, ["audit"], { + ...servers, + [alias]: { enabled: false }, + }), + ).toEqual({ + code_mode: direct("audit", prefixed, bare), + }); + + expect(overrides(false)).toBeUndefined(); + expect(overrides(true)).toBeUndefined(); + expect(overrides(undefined, [prefixed])).toBeUndefined(); + expect( + overrides(undefined, [], { ...servers, [owner]: { enabled: false } }), + ).toBeUndefined(); + expect( + overrides(selected(bare), [], { ...servers, [bare]: { enabled: true } }), + ).toBeUndefined(); +}); + +test("native MCP registrations take precedence over the selected plugin's names and opt-outs", () => { + const owner = "codex-security"; + const alias = "codex.security"; + const plugin = { [owner]: {}, [alias]: {} }; + const noDirect = { omit_tools_from: ["direct"] }; + const overrides = (servers: JsonObject, selected: JsonObject) => + parse( + deepScanParentToolOverrides({ mcp_servers: servers }, selected).join( + "\n", + ), + )["features"]; + const isolated = { + non_prefixed_mcp_tool_names: { enabled: true, server_names: [owner] }, + code_mode: { direct_only_tool_namespaces: ["codex_security"] }, + }; + + expect(overrides({}, plugin)).toEqual(isolated); + expect(overrides({ [alias]: { enabled: false } }, plugin)).toEqual({ + code_mode: { + direct_only_tool_namespaces: ["mcp__codex_security", "codex_security"], + }, + }); + expect(overrides({}, { [alias]: {} })).toBeUndefined(); + expect(overrides({}, { ...plugin, [owner]: noDirect })).toBeUndefined(); + expect(overrides({ [owner]: noDirect }, plugin)).toBeUndefined(); + expect(overrides({ [owner]: {} }, { ...plugin, [owner]: noDirect })).toEqual( + isolated, + ); +}); + test.each([ { name: "inherited Node override", @@ -49,6 +148,38 @@ test.each([ runtimeNode: null, route: "direct", }, + { + name: "installed plugin excludes the direct surface", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + name: "configured server permits direct use despite the installed plugin exclusion", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + pluginOmit: ["direct"], + configuredOmit: [], + }, + { + name: "configured server excludes the direct surface", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + configuredOmit: ["direct"], + directOmitted: true, + }, + { + name: "alternate MCP declaration selected by the plugin", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + alternateMcpSource: "./other-servers.json", + directOmitted: true, + }, { name: "invalid code mode supplied by the caller", dependencyNode: null, @@ -64,6 +195,37 @@ test.each([ await mkdir(repository); await mkdir(codexHome); await mkdir(scanDir, { mode: 0o700 }); + const runtime = preparedRuntime(codexHome); + if (scenario.alternateMcpSource) { + const selected = join(root, "selected-plugin"); + await cp(PLUGIN_ROOT, selected, { recursive: true, dereference: true }); + const path = join(selected, ".codex-plugin", "plugin.json"); + const manifest = JSON.parse(await readFile(path, "utf8")); + await writeFile( + path, + JSON.stringify({ ...manifest, mcpServers: scenario.alternateMcpSource }), + ); + await writeFile( + join(selected, scenario.alternateMcpSource), + JSON.stringify({ + mcpServers: { "codex-security": { omit_tools_from: ["direct"] } }, + }), + ); + runtime.plugin.pluginRoot = selected; + runtime.plugin.installedRoot = selected; + } + if (scenario.pluginOmit) { + runtime.plugin.installedRoot = join(root, "installed-plugin"); + await mkdir(runtime.plugin.installedRoot); + await writeFile( + join(runtime.plugin.installedRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "codex-security": { omit_tools_from: scenario.pluginOmit }, + }, + }), + ); + } const selectedNode = scenario.runtimeNode === null ? undefined @@ -106,6 +268,16 @@ test.each([ const client = new TestClient( { codexOverrides: { + ...(scenario.configuredOmit === undefined + ? {} + : { + mcp_servers: { + "codex-security": { + command: "test-server", + omit_tools_from: [...scenario.configuredOmit], + }, + }, + }), model: "gpt-6.1-sol", model_reasoning_effort: "high", features: { @@ -126,7 +298,7 @@ test.each([ }), }, prepareRuntime: async () => ({ - ...preparedRuntime(codexHome), + ...runtime, environment: runtimeEnvironment, }), // Keep the host's Bun fallback and account advisory out of this check. @@ -144,7 +316,9 @@ test.each([ value.startsWith("features.code_mode.direct_only_tool_namespaces="), ); expect(overrides).toEqual( - scenario.route === "parent" && typeof codeMode !== "string" + scenario.route === "parent" && + typeof codeMode !== "string" && + !scenario.directOmitted ? [ 'features.code_mode.direct_only_tool_namespaces=["existing_tools","mcp__codex_security","codex_security"]', ] diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index b4c5412734..4b3d6c1210 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -8261,6 +8261,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s createCodex: (options: CodexOptions) => { const codex = new Codex(options); return { + resumeThread: codex.resumeThread.bind(codex), startThread: (threadOptions: ThreadOptions) => { const thread = codex.startThread(threadOptions); return { diff --git a/sdk/typescript/tests-ts/component-scan.test.ts b/sdk/typescript/tests-ts/component-scan.test.ts index dff8093455..c38efbc08b 100644 --- a/sdk/typescript/tests-ts/component-scan.test.ts +++ b/sdk/typescript/tests-ts/component-scan.test.ts @@ -34,6 +34,7 @@ import { type ComponentScanEvent, type ComponentScanOptions, } from "../src/component-scan.js"; +import * as deepScan from "../src/deep-scan.js"; import type { Finding, SeverityLevel } from "../src/models.js"; import { ScanResult } from "../src/result.js"; import * as runtime from "../src/runtime.js"; @@ -1854,6 +1855,10 @@ test.each([ return preparedRuntime("unused").plugin; }), spyOn(runtime, "probeCodexSandbox").mockImplementation(async () => {}), + // The Node child below implements only `codex exec`. + spyOn(deepScan, "readDeepScanParentConfig").mockImplementation( + async ({ codexOptions }) => codexOptions.config ?? {}, + ), spyOn(runtime, "resolvePluginPython").mockResolvedValue( "/managed/python", ), From 8def985ba78dc49da63868ea7eaf925ea4519969 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 21:29:41 +0000 Subject: [PATCH 03/13] fix(deep-scan): preserve native values and registered failure follow-ups --- sdk/typescript/src/api.ts | 60 ++++++++++--------- sdk/typescript/src/config.ts | 2 + .../api-deep-scan-runtime-routing.test.ts | 6 +- sdk/typescript/tests-ts/api.test.ts | 26 ++++++++ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 9a44b0cd87..26f0ad109b 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2864,6 +2864,25 @@ export class CodexSecurity { options.auth, git, ); + const threadOptions: ThreadOptions = { + threadSource: CODEX_SECURITY_THREAD_SOURCES.scan, + workingDirectory: scanDir, + skipGitRepoCheck: true, + approvalPolicy, + }; + const turnOptions: TurnOptions = { + signal, + cyberAccessProgram: options.cyberAccessProgram, + }; + const postScanPrompt = options.postScanPrompt; + const openThread = (client: CodexClientLike, id: unknown) => { + if (typeof id !== "string") return client.startThread(threadOptions); + if (!client.resumeThread) + throw new CodexSecurityError( + "The configured Codex client does not support resuming sessions.", + ); + return client.resumeThread(id, threadOptions); + }; let parentCodex = codex; if ( mode === "deep" && @@ -2871,6 +2890,14 @@ export class CodexSecurity { !this.#dependencies.hostedScan && session.deepScanPluginUsesDefaultMcpFile ) { + // The scan is already registered when native routing is inspected. + if (postScanPrompt?.trim()) { + runPostScan = () => + openThread(codex, resumeThreadId).runStreamed( + postScanPrompt, + turnOptions, + ); + } const parentConfig = await ( this.#dependencies.readDeepScanParentConfig ?? readDeepScanParentConfig @@ -2899,27 +2926,14 @@ export class CodexSecurity { ], }); } + runPostScan = null; } - const threadOptions: ThreadOptions = { - threadSource: CODEX_SECURITY_THREAD_SOURCES.scan, - workingDirectory: scanDir, - skipGitRepoCheck: true, - approvalPolicy, - }; - let thread: CodexThreadLike; + const thread = openThread(parentCodex, resumeThreadId); if (typeof resumeThreadId === "string") { - if (parentCodex.resumeThread === undefined) { - throw new CodexSecurityError( - "The configured Codex client does not support resuming sessions.", - ); - } - thread = parentCodex.resumeThread(resumeThreadId, threadOptions); tracker.start(resumeThreadId); if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; await tracker.refresh().catch(reportTrackingError); checkOpen(); - } else { - thread = parentCodex.startThread(threadOptions); } const serializedPaths = normalized.kind === "paths" ? jsonForPrompt(normalized.paths) : null; @@ -2933,11 +2947,6 @@ export class CodexSecurity { await chmod(targetPathsFile, 0o400); } checkOpen(); - const turnOptions: TurnOptions = { - signal, - cyberAccessProgram: options.cyberAccessProgram, - }; - const postScanPrompt = options.postScanPrompt; let scanThreadId = thread.id; if (postScanPrompt?.trim()) { runPostScan = () => { @@ -2945,13 +2954,10 @@ export class CodexSecurity { (parentCodex !== codex || directDeepScan) && scanThreadId !== null ) { - if (!codex.resumeThread) - throw new CodexSecurityError( - "The configured Codex client does not support resuming sessions.", - ); - return codex - .resumeThread(scanThreadId, threadOptions) - .runStreamed(postScanPrompt, turnOptions); + return openThread(codex, scanThreadId).runStreamed( + postScanPrompt, + turnOptions, + ); } const followUp = parentCodex === codex ? thread : codex.startThread(threadOptions); diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 167fb54ade..3f0a5845a4 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -178,6 +178,8 @@ export function inlineToml(value: JsonValue): string { throw new ConfigurationError( "Codex TOML overrides cannot contain null values.", ); + // Match the Codex SDK's numeric spelling, including integers above 2**53 - 1. + if (typeof value === "number" && Number.isFinite(value)) return String(value); if (Array.isArray(value)) return `[${value.map(inlineToml).join(",")}]`; if (isObject(value)) { return `{${Object.entries(value) diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 8813cd677b..fb7e160002 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -359,7 +359,11 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o await read({ features: { "code_mode.direct_only_tool_namespaces": ["first"] }, "features.code_mode.direct_only_tool_namespaces": ["last"], + model_auto_compact_token_limit: 2 ** 53, }), - ).toMatchObject(expected("last")); + ).toMatchObject({ + ...expected("last"), + model_auto_compact_token_limit: 2 ** 53, + }); await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow(); }); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 4b3d6c1210..dcb240bd34 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4970,6 +4970,32 @@ describe("CodexSecurity orchestration", () => { }, ); + test.each([undefined, "Record the failed scan."])( + "preserves the requested post-scan turn when Deep Scan routing fails with %p", + async (postScanPrompt) => { + const { repository, codexHome, scanDir } = await scanDirectories(); + const prompts: string[] = []; + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + canRunDirectDeepScan: () => false, + readDeepScanParentConfig: async () => + fail("native configuration failed"), + createCodex: codexFactory(async (prompt) => { + prompts.push(prompt); + return { events: completedEvents() }; + }), + }); + try { + await expect( + client.run(repository, { mode: "deep", postScanPrompt }), + ).rejects.toThrow("native configuration failed"); + expect(prompts).toEqual(postScanPrompt ? [postScanPrompt] : []); + } finally { + await client.close(); + } + }, + ); + test.each([ ["a completed scan with scope notes", "completed", false, false], ["legacy coverage metadata", "legacy", false, false], From 74e2ca44d35f446fdeed32806edfa4b3b02ec2c8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 21:32:28 +0000 Subject: [PATCH 04/13] fix(deep-scan): match the parent launcher and config directories --- .../deep-scan/permission-profile-preflight.ts | 4 +-- sdk/typescript/src/api.ts | 1 - sdk/typescript/src/deep-scan.ts | 26 ++++++++----------- sdk/typescript/src/provider-profile.ts | 4 +-- .../api-deep-scan-runtime-routing.test.ts | 26 ++++++++++++++++++- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts index af19bb3708..397349d931 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts @@ -57,11 +57,11 @@ type RuntimeConfigReadOptions = Pick< /** Read native effective settings, including managed provider requirements, without a turn. */ export async function readDeepScanRuntimeConfig( - options: RuntimeConfigReadOptions, + options: RuntimeConfigReadOptions & { readonly configCwd?: string }, ): Promise { return withPreflightClient(options, async (client) => { const response = await client.request("config/read", { - cwd: options.cwd, + cwd: options.configCwd ?? options.cwd, includeLayers: false, }); const config = record(response.config); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 26f0ad109b..efe3b6515c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2905,7 +2905,6 @@ export class CodexSecurity { codexOptions, preflightCommand: await this.#providerPreflightCommand(effectiveConfig), - pluginRoot: runtime.plugin.pluginRoot, scanDir, signal, }); diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 1e963fd74d..7ceb49bb90 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -8,7 +8,11 @@ import { inlineToml, type JsonObject } from "./config.js"; import { isRecord } from "./record.js"; import { executablePathForSpawn, type CodexCommand } from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; -import { withCodexPreflightLock } from "./provider-profile.js"; +import { + nativePermissionPreflight, + nativeProfileClient, + withCodexPreflightLock, +} from "./provider-profile.js"; export interface DirectDeepScanOptions { codexOptions: CodexOptions & { nativeProfile?: string }; @@ -35,27 +39,17 @@ function sdkConfigFlags(config: JsonObject, prefix = ""): string[] { export async function readDeepScanParentConfig( options: Pick< DirectDeepScanOptions, - "codexOptions" | "preflightCommand" | "pluginRoot" | "scanDir" | "signal" + "codexOptions" | "preflightCommand" | "scanDir" | "signal" >, ): Promise { const { codexOptions, preflightCommand: command } = options; - const native = await import( - pathToFileURL( - join(options.pluginRoot, "mcp", "permission-profile-preflight.mjs"), - ).href - ); + const native = await nativePermissionPreflight(); const config = codexOptions.config ?? {}; // The provider-profile launcher emits whole top-level tables instead. const overrides = codexOptions.nativeProfile === undefined ? sdkConfigFlags(config) - : ( - await import( - pathToFileURL( - join(options.pluginRoot, "scripts", "codex_profile.mjs"), - ).href - ) - ).profileConfigOverrides(config); + : (await nativeProfileClient()).profileConfigOverrides(config); const environment = bundledCodexSdkEnvironment(command.command, { ...codexOptions.env, ...(codexOptions.apiKey === undefined @@ -66,7 +60,9 @@ export async function readDeepScanParentConfig( native.readDeepScanRuntimeConfig({ codexPath: command.command, commandArgs: command.args, - cwd: options.scanDir, + // The parent launcher inherits this directory and passes the scan via --cd. + cwd: process.cwd(), + configCwd: options.scanDir, configOverrides: [...overrides, ...(codexOptions.configOverrides ?? [])], env: environment, signal: options.signal, diff --git a/sdk/typescript/src/provider-profile.ts b/sdk/typescript/src/provider-profile.ts index def3dd0b67..5f2dd9fd1e 100644 --- a/sdk/typescript/src/provider-profile.ts +++ b/sdk/typescript/src/provider-profile.ts @@ -179,7 +179,7 @@ export async function withCodexPreflightLock( } } -async function nativePermissionPreflight() { +export async function nativePermissionPreflight() { return await import( pathToFileURL( join( @@ -191,7 +191,7 @@ async function nativePermissionPreflight() { ); } -async function nativeProfileClient() { +export async function nativeProfileClient() { return await import( pathToFileURL( join(await bundledPluginRoot(), "scripts", "codex_profile.mjs"), diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index fb7e160002..3850d10071 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -347,7 +347,6 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o readDeepScanParentConfig({ codexOptions: { config, env: { CODEX_HOME: home } }, preflightCommand: resolveCodexCommand({}), - pluginRoot: PLUGIN_ROOT, scanDir, signal: new AbortController().signal, }); @@ -367,3 +366,28 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o }); await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow(); }); + +test("parent configuration launches from the caller and reads the scan directory", async () => { + const home = await temporaryDirectory(); + const scanDir = await temporaryDirectory(); + const script = join(home, "config-reader.mjs"); + await writeFile( + script, + `import { createInterface } from "node:readline"; +for await (const line of createInterface({ input: process.stdin })) { + const { id, method, params } = JSON.parse(line); + if (id === undefined) continue; + const result = method === "config/read" + ? { config: { launcherCwd: process.cwd(), configCwd: params.cwd } } + : {}; + console.log(JSON.stringify({ id, result })); +}`, + ); + const config = await readDeepScanParentConfig({ + codexOptions: { env: { CODEX_HOME: home } }, + preflightCommand: { command: process.execPath, args: [script] }, + scanDir, + signal: new AbortController().signal, + }); + expect(config).toEqual({ launcherCwd: process.cwd(), configCwd: scanDir }); +}); From ab47a485c760899837c957dbdf816cdc3785922d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 21:37:30 +0000 Subject: [PATCH 05/13] fix(deep-scan): report parent configuration failures accurately --- .../deep-scan/permission-profile-preflight.ts | 66 ++++++++++++------- sdk/typescript/src/deep-scan.ts | 2 +- .../api-deep-scan-runtime-routing.test.ts | 4 +- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts index 397349d931..2fae0215ca 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts @@ -47,14 +47,17 @@ type PendingRequest = { type RuntimeConfigReadOptions = Pick< DeepScanPermissionProfilePreflightOptions, - "codexPath" | "cwd" | "configOverrides" | "env" | "signal" | "context" + "codexPath" | "cwd" | "configOverrides" | "env" | "signal" > & { + readonly context?: PreflightContext; readonly commandArgs?: readonly string[]; /** Full app-server invocation for callers that already constructed its arguments. */ readonly appServerArgs?: readonly string[]; readonly providerConfigOverrides?: readonly string[]; }; +type PreflightContext = "helper" | "parent-config"; + /** Read native effective settings, including managed provider requirements, without a turn. */ export async function readDeepScanRuntimeConfig( options: RuntimeConfigReadOptions & { readonly configCwd?: string }, @@ -266,7 +269,8 @@ async function withPreflightClient( options: RuntimeConfigReadOptions, operation: (client: AppServerPreflightClient) => Promise, ): Promise { - if (options.signal.aborted) throw abortError(options.signal.reason); + if (options.signal.aborted) + throw abortError(options.signal.reason, options.context); const client = new AppServerPreflightClient(options); try { await client.initialize(); @@ -361,7 +365,7 @@ class AppServerPreflightClient { }); const onAbort = () => { - this.fail(abortError(options.signal.reason)); + this.fail(abortError(options.signal.reason, options.context)); this.stopChild(); }; options.signal.addEventListener("abort", onAbort, { once: true }); @@ -667,21 +671,28 @@ function profileCollisionError(context?: "helper"): DeepScanNonRetryableError { ); } -function malformedPreflightError(context?: "helper"): Error { +function failedOperation(context?: PreflightContext): string { + return context === "parent-config" + ? "Could not read the Deep Scan parent configuration" + : `${subject(context)} cannot safely verify its read-only worker permission profile`; +} + +function malformedPreflightError(context?: PreflightContext): Error { return new Error( - `${subject(context)} cannot safely verify its read-only worker permission profile with this Codex configuration. ${subject(context)} did not run.`, + context === "parent-config" + ? `${failedOperation(context)}. Codex returned an invalid app-server response.` + : `${failedOperation(context)} with this Codex configuration. ${subject(context)} did not run.`, ); } function unsupportedCodexApiError( codexPath: string, api: string, - context?: "helper", + context?: PreflightContext, ): DeepScanNonRetryableError { return new DeepScanNonRetryableError( - subject(context) + - " cannot safely verify its read-only worker permission profile because " + - "the selected Codex executable " + + failedOperation(context) + + " because the selected Codex executable " + JSON.stringify(codexPath) + " does not support the required " + JSON.stringify(api) + @@ -698,7 +709,7 @@ function jsonRpcPreflightError( codexPath: string, method: string, value: unknown, - context?: "helper", + context?: PreflightContext, ): Error { const error = record(value); const code = @@ -712,9 +723,8 @@ function jsonRpcPreflightError( return failure; } return new Error( - subject(context) + - " cannot safely verify its read-only worker permission profile because " + - "the selected Codex executable " + + failedOperation(context) + + " because the selected Codex executable " + JSON.stringify(codexPath) + " returned an error for " + JSON.stringify(method) + @@ -730,7 +740,7 @@ function jsonRpcPreflightError( function codexExecutableStartError( codexPath: string, error: Error, - context?: "helper", + context?: PreflightContext, ): Error { const value = "code" in error ? error.code : undefined; const code = @@ -754,22 +764,24 @@ function codexExecutableExitError( codexPath: string, code: number | null, signal: NodeJS.Signals | null, - context?: "helper", + context?: PreflightContext, ): Error { + const operation = + context === "parent-config" + ? "the configuration read" + : "permission-profile verification"; const detail = code !== null - ? "exited before permission-profile verification completed with code " + - code + ? `exited before ${operation} completed with code ${code}` : signal !== null - ? "was terminated before permission-profile verification completed by signal " + - signal - : "exited before permission-profile verification completed"; + ? `was terminated before ${operation} completed by signal ${signal}` + : `exited before ${operation} completed`; return new Error(codexExecutableFailureMessage(codexPath, detail, context)); } function codexExecutableStdioError( codexPath: string, - context?: "helper", + context?: PreflightContext, ): Error { return new Error( codexExecutableFailureMessage( @@ -783,8 +795,10 @@ function codexExecutableStdioError( function codexExecutableFailureMessage( codexPath: string, detail: string, - context?: "helper", + context?: PreflightContext, ): string { + if (context === "parent-config") + return `${failedOperation(context)}. Codex executable ${JSON.stringify(codexPath)} ${detail}.`; return ( subject(context) + " cannot safely verify its read-only worker permission profile because " + @@ -822,7 +836,7 @@ export function deepScanPermissionProfileFallbackError( ); } -function subject(context?: "helper"): string { +function subject(context?: PreflightContext): string { return context === "helper" ? "Read-only Codex helper" : "Deep Scan"; } @@ -830,10 +844,12 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } -function abortError(reason: unknown): Error { +function abortError(reason: unknown, context?: PreflightContext): Error { if (reason instanceof Error) return reason; return new DOMException( - "Deep Scan worker permission profile preflight aborted.", + context === "parent-config" + ? "Deep Scan parent configuration read aborted." + : "Deep Scan worker permission profile preflight aborted.", "AbortError", ); } diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 7ceb49bb90..629ba447e3 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -66,7 +66,7 @@ export async function readDeepScanParentConfig( configOverrides: [...overrides, ...(codexOptions.configOverrides ?? [])], env: environment, signal: options.signal, - context: "helper", + context: "parent-config", }), ); } diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 3850d10071..39244464dd 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -364,7 +364,9 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o ...expected("last"), model_auto_compact_token_limit: 2 ** 53, }); - await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow(); + await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow( + /Could not read the Deep Scan parent configuration[\s\S]*code_mode/u, + ); }); test("parent configuration launches from the caller and reads the scan directory", async () => { From a32d7369f668849cc9e1bd508897f7028cf99723 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 21:55:14 +0000 Subject: [PATCH 06/13] Ignore MCP peers whose tool allowlist is empty --- sdk/typescript/src/deep-scan.ts | 4 +++- .../tests-ts/api-deep-scan-runtime-routing.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 629ba447e3..5a8de2246b 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -116,7 +116,9 @@ export function deepScanParentToolOverrides( ([server, settings]) => server !== owner && isRecord(settings) && - settings["enabled"] !== false, + settings["enabled"] !== false && + (!Array.isArray(settings["enabled_tools"]) || + settings["enabled_tools"].length > 0), ) .map(([server]) => namespace(server)), ); diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 39244464dd..cf5a0ffcdf 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -76,6 +76,15 @@ test("a colliding MCP server leaves only the original coordinator's namespace di code_mode: direct("audit", prefixed, bare), }); + expect( + overrides(false, ["audit"], { ...servers, [alias]: { enabled_tools: [] } }), + ).toEqual({ code_mode: direct("audit", prefixed, bare) }); + expect( + overrides(false, ["audit"], { + ...servers, + [alias]: { enabled_tools: ["peer_ping"] }, + }), + ).toBeUndefined(); expect(overrides(false)).toBeUndefined(); expect(overrides(true)).toBeUndefined(); expect(overrides(undefined, [prefixed])).toBeUndefined(); From e3c30468bc1c02b9716203153a1599406bbdb9dc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:07:19 +0000 Subject: [PATCH 07/13] fix(deep-scan): route the coordinator from its installed plugin declaration --- .../deep-scan/permission-profile-preflight.ts | 32 ++- sdk/typescript/src/api.ts | 89 +++++--- sdk/typescript/src/deep-scan.ts | 5 +- .../api-deep-scan-runtime-routing.test.ts | 195 ++++++++++++++---- 4 files changed, 251 insertions(+), 70 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts index 2fae0215ca..f597537a34 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts @@ -60,8 +60,11 @@ type PreflightContext = "helper" | "parent-config"; /** Read native effective settings, including managed provider requirements, without a turn. */ export async function readDeepScanRuntimeConfig( - options: RuntimeConfigReadOptions & { readonly configCwd?: string }, -): Promise { + options: RuntimeConfigReadOptions & { + readonly configCwd?: string; + readonly plugin?: { marketplacePath: string; pluginName: string }; + }, +): Promise { return withPreflightClient(options, async (client) => { const response = await client.request("config/read", { cwd: options.configCwd ?? options.cwd, @@ -69,7 +72,30 @@ export async function readDeepScanRuntimeConfig( }); const config = record(response.config); if (!config) throw malformedPreflightError(options.context); - return config; + if (!options.plugin) return config; + // Portable plugins expose their admitted server names through native discovery. + // Preserve native routing if the selected Codex cannot provide that metadata. + const detail = await client + .request("plugin/read", options.plugin) + .catch(() => undefined); + options.signal.throwIfAborted(); + const plugin = record(detail?.plugin); + const summary = record(plugin?.summary); + const servers = plugin?.mcpServers; + if ( + summary?.installed !== true || + summary.enabled !== true || + !Array.isArray(servers) || + !servers.every((name) => typeof name === "string") + ) + return undefined; + return { + ...config, + mcp_servers: { + ...Object.fromEntries(servers.map((name) => [name, {}])), + ...record(config.mcp_servers), + }, + }; }); } diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index efe3b6515c..5470de7f72 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -30,6 +30,7 @@ import { dirname, isAbsolute, join, + parse as parsePath, posix, relative, resolve, @@ -127,7 +128,7 @@ import { type ScanSettings, type ScanPromptSettings, } from "./scan-settings.js"; -import { resolveScanPrompts } from "./prompt-files.js"; +import { readRegularInputFile, resolveScanPrompts } from "./prompt-files.js"; import { readScanTimeBudgetPolicy, renderScanTimeBudgetPolicy, @@ -310,7 +311,6 @@ interface PreparedRuntime { } interface PreparedSession { - deepScanPluginUsesDefaultMcpFile?: boolean; safetyIdentifier?: string; runtime: PreparedRuntime; runtimeHome: string; @@ -2884,12 +2884,13 @@ export class CodexSecurity { return client.resumeThread(id, threadOptions); }; let parentCodex = codex; - if ( - mode === "deep" && - !directDeepScan && - !this.#dependencies.hostedScan && - session.deepScanPluginUsesDefaultMcpFile - ) { + const pluginServers = + mode === "deep" && !directDeepScan && !this.#dependencies.hostedScan + ? await readInstalledDeepScanPluginServers( + runtime.plugin.installedRoot, + ) + : undefined; + if (pluginServers !== undefined) { // The scan is already registered when native routing is inspected. if (postScanPrompt?.trim()) { runPostScan = () => @@ -2907,14 +2908,25 @@ export class CodexSecurity { await this.#providerPreflightCommand(effectiveConfig), scanDir, signal, + ...(pluginServers === "native" && { + plugin: { + marketplacePath: join( + runtime.plugin.marketplaceRoot, + ".agents", + "plugins", + "marketplace.json", + ), + pluginName: runtime.plugin.name, + }, + }), }); - const pluginServers = await readDeepScanPluginServers( - runtime.plugin.installedRoot, - ); const overrides = - pluginServers === undefined + parentConfig === undefined ? [] - : deepScanParentToolOverrides(parentConfig, pluginServers); + : deepScanParentToolOverrides( + parentConfig, + pluginServers === "native" ? {} : pluginServers, + ); if (overrides.length > 0) { // Only the coordinator turn needs to keep its MCP call outside a yielding cell. parentCodex = await this.#dependencies.createCodex({ @@ -4133,9 +4145,7 @@ export class CodexSecurity { ); } const runtime = this.#runtime; - const mcpSource = deepScan - ? (await requireCurrentDeepScanPlugin(runtime))["mcpServers"] - : null; + if (deepScan) await requireCurrentDeepScanPlugin(runtime); const effectiveConfig = await scanSkillConfig( scanCyberAccessConfig(requestedConfig, options.cyberAccessProgram), runtime.plugin, @@ -4253,11 +4263,6 @@ export class CodexSecurity { checkOpen(); return { runtime, - // Leave other plugin declarations under the caller's native routing policy. - deepScanPluginUsesDefaultMcpFile: - mcpSource === undefined || - mcpSource === "./.mcp.json" || - mcpSource === ".mcp.json", safetyIdentifier: options.safetyIdentifier, runtimeHome, effectiveConfig, @@ -6183,7 +6188,7 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { async function requireCurrentDeepScanPlugin( runtime: PreparedRuntime, -): Promise> { +): Promise { const manifest: unknown = JSON.parse( await readFile( join(runtime.plugin.pluginRoot, ".codex-plugin", "plugin.json"), @@ -6201,7 +6206,6 @@ async function requireCurrentDeepScanPlugin( "Deep Scan requires the current plugin's direct engine and isolated worker runtime support. Update the custom plugin or use the bundled plugin.", ); } - return manifest; } function selectedWorkerRuntimeConfig( @@ -6262,6 +6266,45 @@ function selectedWorkerRuntimeConfig( }; } +/** Read the declaration selected by the installed Codex plugin manifest. */ +async function readInstalledDeepScanPluginServers( + installedRoot: string, +): Promise | "native" | undefined> { + const read = async (path: string): Promise => + JSON.parse( + await readRegularInputFile(join(installedRoot, path), installedRoot), + ); + try { + const portable = await read("plugin.json").catch(() => undefined); + // A portable manifest owns MCP. Its legacy overlay only forwards environment variables. + if ( + isRecord(portable) && + typeof portable["$schema"] === "string" && + portable["$schema"].startsWith("https://agent-plugins.org/schemas/") + ) + return "native"; + const manifest = await read(".codex-plugin/plugin.json"); + if (!isRecord(manifest)) return; + let source = manifest["mcpServers"]; + if (!isRecord(source)) { + const path = + typeof source === "string" && source.startsWith("./") + ? source.slice(2) + : ""; + // Native Codex ignores invalid selectors and uses the installed default. + const valid = + path && + !parsePath(path).root && + !path.split(sep === "\\" ? /[/\\]/u : "/").includes(".."); + source = await read(valid ? path : ".mcp.json"); + } + if (!isRecord(source)) return; + return isRecord(source["mcpServers"]) ? source["mcpServers"] : source; + } catch { + return undefined; + } +} + async function readDeepScanPluginServers( pluginRoot: string, ): Promise | undefined> { diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 5a8de2246b..36816b3dd9 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -40,8 +40,8 @@ export async function readDeepScanParentConfig( options: Pick< DirectDeepScanOptions, "codexOptions" | "preflightCommand" | "scanDir" | "signal" - >, -): Promise { + > & { plugin?: { marketplacePath: string; pluginName: string } }, +): Promise { const { codexOptions, preflightCommand: command } = options; const native = await nativePermissionPreflight(); const config = codexOptions.config ?? {}; @@ -67,6 +67,7 @@ export async function readDeepScanParentConfig( env: environment, signal: options.signal, context: "parent-config", + plugin: options.plugin, }), ); } diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index cf5a0ffcdf..c89249f367 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, mock, test } from "bun:test"; import type { CodexOptions } from "@openai/codex-sdk"; @@ -126,6 +126,12 @@ test("native MCP registrations take precedence over the selected plugin's names ); }); +const parentWithNode = { + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", +} as const; + test.each([ { name: "inherited Node override", @@ -182,11 +188,42 @@ test.each([ directOmitted: true, }, { - name: "alternate MCP declaration selected by the plugin", - dependencyNode: null, - runtimeNode: "selected runtime", - route: "parent", - alternateMcpSource: "./other-servers.json", + ...parentWithNode, + name: "inline MCP declaration allowing direct calls", + declaration: "inline", + pluginOmit: [], + }, + { + ...parentWithNode, + name: "inline MCP declaration excluding direct calls", + declaration: "inline", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + ...parentWithNode, + name: "alternate MCP declaration allowing direct calls", + declaration: "alternate", + pluginOmit: [], + }, + { + ...parentWithNode, + name: "alternate MCP declaration excluding direct calls", + declaration: "alternate", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + ...parentWithNode, + name: "the installed default when an alternate MCP selector contains a parent component", + declaration: "invalid", + pluginOmit: ["direct"], + }, + { + ...parentWithNode, + name: "an alternate MCP file reached through a link outside the installed plugin", + declaration: "outside", + pluginOmit: [], directOmitted: true, }, { @@ -205,35 +242,55 @@ test.each([ await mkdir(codexHome); await mkdir(scanDir, { mode: 0o700 }); const runtime = preparedRuntime(codexHome); - if (scenario.alternateMcpSource) { - const selected = join(root, "selected-plugin"); - await cp(PLUGIN_ROOT, selected, { recursive: true, dereference: true }); - const path = join(selected, ".codex-plugin", "plugin.json"); - const manifest = JSON.parse(await readFile(path, "utf8")); - await writeFile( - path, - JSON.stringify({ ...manifest, mcpServers: scenario.alternateMcpSource }), + if (scenario.pluginOmit) { + const installed = join(root, "installed-plugin"); + await mkdir(join(installed, ".codex-plugin"), { recursive: true }); + const manifest = JSON.parse( + await readFile(join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), "utf8"), ); + const servers = (omit: readonly string[]) => ({ + "codex-security": { command: "fixture", omit_tools_from: omit }, + }); + const declared = servers(scenario.pluginOmit); + let defaults = declared; + if ("declaration" in scenario) { + // Only the installed manifest selects this declaration. + defaults = servers(scenario.directOmitted ? [] : ["direct"]); + if (scenario.declaration === "inline") { + manifest.mcpServers = declared; + } else { + manifest.mcpServers = "./routing/servers.json"; + const routing = join(installed, "routing"); + const linked = scenario.declaration === "outside"; + const directory = linked ? join(root, "outside-plugin") : routing; + await mkdir(directory); + if (scenario.declaration === "invalid") { + await mkdir(join(directory, "nested")); + manifest.mcpServers = "./routing/nested/../servers.json"; + defaults = servers([]); + } + if (linked) { + await symlink( + directory, + routing, + process.platform === "win32" ? "junction" : "dir", + ); + } + await writeFile( + join(directory, "servers.json"), + JSON.stringify({ mcpServers: declared }), + ); + } + } await writeFile( - join(selected, scenario.alternateMcpSource), - JSON.stringify({ - mcpServers: { "codex-security": { omit_tools_from: ["direct"] } }, - }), + join(installed, ".mcp.json"), + JSON.stringify({ mcpServers: defaults }), ); - runtime.plugin.pluginRoot = selected; - runtime.plugin.installedRoot = selected; - } - if (scenario.pluginOmit) { - runtime.plugin.installedRoot = join(root, "installed-plugin"); - await mkdir(runtime.plugin.installedRoot); await writeFile( - join(runtime.plugin.installedRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - "codex-security": { omit_tools_from: scenario.pluginOmit }, - }, - }), + join(installed, ".codex-plugin", "plugin.json"), + JSON.stringify(manifest), ); + runtime.plugin.installedRoot = installed; } const selectedNode = scenario.runtimeNode === null @@ -378,27 +435,81 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o ); }); -test("parent configuration launches from the caller and reads the scan directory", async () => { +test("parent configuration reads the scan settings and selected plugin from the caller", async () => { const home = await temporaryDirectory(); const scanDir = await temporaryDirectory(); const script = join(home, "config-reader.mjs"); + const log = join(home, "requests.json"); await writeFile( script, - `import { createInterface } from "node:readline"; + `import { writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +const [log, replies] = process.argv.slice(2); +const responses = JSON.parse(replies); +const calls = []; for await (const line of createInterface({ input: process.stdin })) { const { id, method, params } = JSON.parse(line); if (id === undefined) continue; - const result = method === "config/read" - ? { config: { launcherCwd: process.cwd(), configCwd: params.cwd } } - : {}; - console.log(JSON.stringify({ id, result })); + if (method !== "initialize") { + calls.push({ method, params }); + writeFileSync(log, JSON.stringify({ cwd: process.cwd(), calls })); + } + console.log(JSON.stringify({ id, ...(responses[method] ?? { result: {} }) })); }`, ); - const config = await readDeepScanParentConfig({ - codexOptions: { env: { CODEX_HOME: home } }, - preflightCommand: { command: process.execPath, args: [script] }, - scanDir, - signal: new AbortController().signal, + const plugin = { + marketplacePath: join(home, "marketplace.json"), + pluginName: "selected-security", + }; + const owner = "codex-security"; + const alias = "codex.security"; + const noDirect = { omit_tools_from: ["direct"] }; + const config = { mcp_servers: { [owner]: noDirect } }; + const read = ( + detail: JsonObject, + settings: JsonObject = { result: { config } }, + ) => + readDeepScanParentConfig({ + codexOptions: { env: { CODEX_HOME: home } }, + preflightCommand: { + command: process.execPath, + args: [ + script, + log, + JSON.stringify({ "config/read": settings, "plugin/read": detail }), + ], + }, + scanDir, + signal: new AbortController().signal, + plugin, + }); + const requests = async () => JSON.parse(await readFile(log, "utf8")); + const configRead = { + method: "config/read", + params: { cwd: scanDir, includeLayers: false }, + }; + const detail = { + result: { + plugin: { + summary: { installed: true, enabled: true }, + mcpServers: [owner, alias], + }, + }, + }; + expect(await read(detail)).toEqual({ + mcp_servers: { [owner]: noDirect, [alias]: {} }, + }); + expect(await requests()).toEqual({ + cwd: process.cwd(), + calls: [configRead, { method: "plugin/read", params: plugin }], }); - expect(config).toEqual({ launcherCwd: process.cwd(), configCwd: scanDir }); + expect( + await read({ error: { code: -32601, message: "Method not found" } }), + ).toBeUndefined(); + await expect( + read(detail, { error: { code: -32602, message: "invalid configuration" } }), + ).rejects.toThrow( + /Could not read the Deep Scan parent configuration[\s\S]*invalid configuration/u, + ); + expect((await requests()).calls).toEqual([configRead]); }); From 45f53570ecffc3d4e0cb47eedea44a5faa429689 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:13:13 +0000 Subject: [PATCH 08/13] fix(sdk): prepare scan scope and resumed budget before routing --- sdk/typescript/src/api.ts | 38 ++++++------ sdk/typescript/tests-ts/api.test.ts | 90 ++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 5470de7f72..ea67044c0e 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2883,6 +2883,25 @@ export class CodexSecurity { ); return client.resumeThread(id, threadOptions); }; + if (typeof resumeThreadId === "string") { + tracker.start(resumeThreadId); + if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; + await tracker.refresh().catch(reportTrackingError); + } + checkOpen(); + if (normalized.kind === "paths" && targetPathsFile !== null) { + await writeFile( + targetPathsFile, + `${jsonForPrompt(normalized.paths)}\n`, + { + flag: "wx", + mode: 0o400, + signal, + }, + ); + await chmod(targetPathsFile, 0o400); + checkOpen(); + } let parentCodex = codex; const pluginServers = mode === "deep" && !directDeepScan && !this.#dependencies.hostedScan @@ -2939,25 +2958,8 @@ export class CodexSecurity { } runPostScan = null; } - const thread = openThread(parentCodex, resumeThreadId); - if (typeof resumeThreadId === "string") { - tracker.start(resumeThreadId); - if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; - await tracker.refresh().catch(reportTrackingError); - checkOpen(); - } - const serializedPaths = - normalized.kind === "paths" ? jsonForPrompt(normalized.paths) : null; - checkOpen(); - if (serializedPaths !== null && targetPathsFile !== null) { - await writeFile(targetPathsFile, `${serializedPaths}\n`, { - flag: "wx", - mode: 0o400, - signal, - }); - await chmod(targetPathsFile, 0o400); - } checkOpen(); + const thread = openThread(parentCodex, resumeThreadId); let scanThreadId = thread.id; if (postScanPrompt?.trim()) { runPostScan = () => { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index dcb240bd34..fd16a3ad1a 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4974,28 +4974,104 @@ describe("CodexSecurity orchestration", () => { "preserves the requested post-scan turn when Deep Scan routing fails with %p", async (postScanPrompt) => { const { repository, codexHome, scanDir } = await scanDirectories(); - const prompts: string[] = []; + const target = ["api", "notes.md"]; + await mkdir(join(repository, "api")); + await writeFile(join(repository, "notes.md"), "fixture"); + const turns: Array<{ prompt: string; paths: string[] }> = []; + const warnings: string[] = []; const client = TestClient.withDependencies({ ...scanRuntimeDependencies(codexHome, scanDir), canRunDirectDeepScan: () => false, readDeepScanParentConfig: async () => fail("native configuration failed"), - createCodex: codexFactory(async (prompt) => { - prompts.push(prompt); - return { events: completedEvents() }; - }), + createCodex: async ({ env }) => + codexFactory(async (prompt: string) => { + const pathsFile = env?.["CODEX_SECURITY_TARGET_PATHS_FILE"]; + if (!pathsFile) throw new Error("Missing scope file location"); + turns.push({ + prompt, + paths: JSON.parse(await readFile(pathsFile, "utf8")), + }); + return { events: completedEvents() }; + })(), }); try { await expect( - client.run(repository, { mode: "deep", postScanPrompt }), + client.run(repository, { + mode: "deep", + target, + postScanPrompt, + onWarning: (warning) => warnings.push(warning), + }), ).rejects.toThrow("native configuration failed"); - expect(prompts).toEqual(postScanPrompt ? [postScanPrompt] : []); + expect(warnings).toEqual([]); + expect(turns).toEqual( + postScanPrompt ? [{ prompt: postScanPrompt, paths: target }] : [], + ); } finally { await client.close(); } }, ); + test("checks the resumed budget before routing a Deep Scan failure follow-up", async () => { + const { repository, codexHome, scanDir } = await scanDirectories(); + const scanId = "scan_example_001"; + const threadId = "thread-1"; + await writeUsageSession( + codexHome, + threadId, + { + input_tokens: 2_000, + output_tokens: 0, + total_tokens: 2_000, + }, + { cwd: scanDir }, + ); + const readConfig = mock(rejecting("native configuration failed")); + const runStreamed = mock(async () => ({ + events: completedEvents(threadId), + })); + const thread = { id: threadId, runStreamed }; + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + canRunDirectDeepScan: () => false, + readDeepScanParentConfig: readConfig, + createCodex: () => ({ + startThread: () => thread, + resumeThread: () => thread, + }), + runWorkbench: async (_options, args, input): Promise => { + if (args[0] === "get-cli-scan-resume") + return { + scanId, + scanDir, + threadId, + targetId: "target_sha256_example", + targetRevision: "deadbeef", + recipe: { repository, target: { kind: "repository", paths: [] } }, + contract: { target: { allowedKinds: ["git_revision"] } }, + }; + return mockWorkbench(args, input); + }, + }); + try { + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + maxCostUsd: 0.004, + postScanPrompt: "Summarize the failed scan.", + }), + ).rejects.toBeInstanceOf(ScanCostLimitExceededError); + expect(readConfig).not.toHaveBeenCalled(); + expect(runStreamed).not.toHaveBeenCalled(); + } finally { + await client.close(); + } + }); + test.each([ ["a completed scan with scope notes", "completed", false, false], ["legacy coverage metadata", "legacy", false, false], From ae23af61e11c431c86801bfebeccab0b2bb9a40c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:32:23 +0000 Subject: [PATCH 09/13] fix(deep-scan): scope native configuration before startup --- .../deep-scan/permission-profile-preflight.ts | 3 +- sdk/typescript/src/deep-scan.ts | 47 +++++++++++++++++-- .../api-deep-scan-runtime-routing.test.ts | 22 +++++---- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts index f597537a34..02e350f030 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts @@ -51,6 +51,7 @@ type RuntimeConfigReadOptions = Pick< > & { readonly context?: PreflightContext; readonly commandArgs?: readonly string[]; + readonly configCwd?: string; /** Full app-server invocation for callers that already constructed its arguments. */ readonly appServerArgs?: readonly string[]; readonly providerConfigOverrides?: readonly string[]; @@ -61,7 +62,6 @@ type PreflightContext = "helper" | "parent-config"; /** Read native effective settings, including managed provider requirements, without a turn. */ export async function readDeepScanRuntimeConfig( options: RuntimeConfigReadOptions & { - readonly configCwd?: string; readonly plugin?: { marketplacePath: string; pluginName: string }; }, ): Promise { @@ -350,6 +350,7 @@ class AppServerPreflightClient { args.push("--config", override); } args.push("app-server", "--stdio"); + if (options.configCwd !== undefined) args.push("--cd", options.configCwd); } this.child = spawn(executablePathForSpawn(options.codexPath), args, { diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 36816b3dd9..43027b7511 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -2,11 +2,16 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { CodexOptions } from "@openai/codex-sdk"; +import { parse } from "smol-toml"; import { bundledCodexSdkEnvironment } from "./codex-sdk-environment.js"; import { CodexSecurityError } from "./errors.js"; import { inlineToml, type JsonObject } from "./config.js"; import { isRecord } from "./record.js"; -import { executablePathForSpawn, type CodexCommand } from "./runtime.js"; +import { + executablePathForSpawn, + runCodexCommand, + type CodexCommand, +} from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; import { nativePermissionPreflight, @@ -35,6 +40,26 @@ function sdkConfigFlags(config: JsonObject, prefix = ""): string[] { }); } +// Older app-servers cannot read the scan's native configuration without first +// loading the caller's project. Keep their coordinator routing scoped to the +// settings supplied to exec; native policy still applies to the actual turn. +function suppliedParentRouting( + overrides: readonly string[], +): JsonObject | undefined { + const routing = new Map(); + for (const override of overrides) { + const key = override.slice(0, override.indexOf("=")); + if (/^(?:features|mcp_servers)(?:\.|$)/u.test(key)) + routing.set(key, override); + } + try { + return parse([...routing.values()].join("\n")) as JsonObject; + } catch { + // Leave overlapping or malformed TOML for exec's own parser. + return undefined; + } +} + /** Read the same native layers and CLI overrides as the parent before adding its tool. */ export async function readDeepScanParentConfig( options: Pick< @@ -43,7 +68,6 @@ export async function readDeepScanParentConfig( > & { plugin?: { marketplacePath: string; pluginName: string } }, ): Promise { const { codexOptions, preflightCommand: command } = options; - const native = await nativePermissionPreflight(); const config = codexOptions.config ?? {}; // The provider-profile launcher emits whole top-level tables instead. const overrides = @@ -56,6 +80,23 @@ export async function readDeepScanParentConfig( ? {} : { CODEX_API_KEY: codexOptions.apiKey }), }); + const configOverrides = [ + ...overrides, + ...(codexOptions.configOverrides ?? []), + ]; + // Subcommand help does not load a project. A global --cd is ignored by + // older app-servers, so require support on the subcommand itself. + const help = await runCodexCommand( + command, + ["app-server", "--cd", options.scanDir, "--help"], + environment, + undefined, + options.signal, + ); + if (!help.success || !/^\s*(?:-C,\s*)?--cd\s+<[^>\n]+>/mu.test(help.stdout)) + return options.plugin ? undefined : suppliedParentRouting(configOverrides); + + const native = await nativePermissionPreflight(); return withCodexPreflightLock(environment, options.signal, () => native.readDeepScanRuntimeConfig({ codexPath: command.command, @@ -63,7 +104,7 @@ export async function readDeepScanParentConfig( // The parent launcher inherits this directory and passes the scan via --cd. cwd: process.cwd(), configCwd: options.scanDir, - configOverrides: [...overrides, ...(codexOptions.configOverrides ?? [])], + configOverrides, env: environment, signal: options.signal, context: "parent-config", diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index c89249f367..75f0d6ca2f 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -402,7 +402,7 @@ test.each([ } }); -test("parent configuration reads inherited namespaces and preserves dotted SDK override order", async () => { +test("older app-servers retain supplied routing without starting in the caller project", async () => { const home = await temporaryDirectory(); const scanDir = await temporaryDirectory(); await writeFile( @@ -419,23 +419,22 @@ test("parent configuration reads inherited namespaces and preserves dotted SDK o const expected = (name: string) => ({ features: { code_mode: { direct_only_tool_namespaces: [name] } }, }); - expect(await read({})).toMatchObject(expected("inherited_tools")); + expect( + await read({ model_instructions_file: "missing-in-caller.md" }), + ).toEqual({}); expect( await read({ features: { "code_mode.direct_only_tool_namespaces": ["first"] }, "features.code_mode.direct_only_tool_namespaces": ["last"], model_auto_compact_token_limit: 2 ** 53, }), - ).toMatchObject({ - ...expected("last"), - model_auto_compact_token_limit: 2 ** 53, + ).toEqual(expected("last")); + expect(await read({ "features.code_mode": "invalid" })).toEqual({ + features: { code_mode: "invalid" }, }); - await expect(read({ "features.code_mode": "invalid" })).rejects.toThrow( - /Could not read the Deep Scan parent configuration[\s\S]*code_mode/u, - ); }); -test("parent configuration reads the scan settings and selected plugin from the caller", async () => { +test("a capable app-server launches from the caller and reads the scan settings and selected plugin", async () => { const home = await temporaryDirectory(); const scanDir = await temporaryDirectory(); const script = join(home, "config-reader.mjs"); @@ -444,6 +443,8 @@ test("parent configuration reads the scan settings and selected plugin from the script, `import { writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; +if (process.argv.includes("--help")) { console.log(" -C, --cd "); process.exit(0); } +const startupCwd = process.argv[process.argv.indexOf("--cd") + 1]; const [log, replies] = process.argv.slice(2); const responses = JSON.parse(replies); const calls = []; @@ -452,7 +453,7 @@ for await (const line of createInterface({ input: process.stdin })) { if (id === undefined) continue; if (method !== "initialize") { calls.push({ method, params }); - writeFileSync(log, JSON.stringify({ cwd: process.cwd(), calls })); + writeFileSync(log, JSON.stringify({ cwd: process.cwd(), startupCwd, calls })); } console.log(JSON.stringify({ id, ...(responses[method] ?? { result: {} }) })); }`, @@ -501,6 +502,7 @@ for await (const line of createInterface({ input: process.stdin })) { }); expect(await requests()).toEqual({ cwd: process.cwd(), + startupCwd: scanDir, calls: [configRead, { method: "plugin/read", params: plugin }], }); expect( From 7670bb1ea16fe1653711d5105ea753d355ef8f5a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:36:19 +0000 Subject: [PATCH 10/13] fix: preserve native MCP routing for guarded Deep Scan peers --- sdk/typescript/src/deep-scan.ts | 22 ++++++++++ .../api-deep-scan-runtime-routing.test.ts | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 43027b7511..8487857b7c 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -166,12 +166,34 @@ export function deepScanParentToolOverrides( ); const current = namespace(owner); if (occupied.has(current)) { + // A configured transport can select the raw server without renaming peers. + // Adding this leaf to a plugin-only registration would shadow its transport. + if ( + isRecord(configured) && + Object.hasOwn(configured, owner) && + (typeof ownerConfig["command"] === "string" || + typeof ownerConfig["url"] === "string") + ) { + if (omitted != null && !Array.isArray(omitted)) return []; + const surfaces = omitted ?? []; + if (surfaces.includes("code_mode") && surfaces.includes("deferred")) + return []; + return [ + `mcp_servers.${owner}.omit_tools_from=${inlineToml([...new Set([...surfaces, "code_mode", "deferred"])])}`, + ]; + } // Move only this raw server into a free namespace. Preserve an explicit // naming policy and avoid making the other server inherit a direct slot. if ( current === bare || occupied.has(bare) || direct.includes(current) || + // Moving the owner invalidates an explicitly routed, disambiguated peer. + direct.some( + (name) => + typeof name === "string" && + /^mcp__codex_security_[0-9a-f]{12}$/u.test(name), + ) || (option != null && !enabled) ) return []; diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 75f0d6ca2f..1627ccbd40 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -88,6 +88,7 @@ test("a colliding MCP server leaves only the original coordinator's namespace di expect(overrides(false)).toBeUndefined(); expect(overrides(true)).toBeUndefined(); expect(overrides(undefined, [prefixed])).toBeUndefined(); + expect(overrides(undefined, [`${prefixed}_9721dc6aa9d2`])).toBeUndefined(); expect( overrides(undefined, [], { ...servers, [owner]: { enabled: false } }), ).toBeUndefined(); @@ -96,6 +97,46 @@ test("a colliding MCP server leaves only the original coordinator's namespace di ).toBeUndefined(); }); +test("a configured owner stays direct across native tool filtering without changing a sibling's routing", () => { + const owner = "codex-security"; + const peer = "codex_security"; + const guarded = "mcp__codex_security_9721dc6aa9d2"; + const overrides = (transport: JsonObject, selected: string[]) => + parse( + deepScanParentToolOverrides( + { + mcp_servers: { + [owner]: transport, + [peer]: { command: "peer", enabled_tools: selected }, + }, + features: { + non_prefixed_mcp_tool_names: false, + code_mode: { direct_only_tool_namespaces: [guarded] }, + }, + }, + {}, + ).join("\n"), + ); + const transports: JsonObject[] = [ + { command: "coordinator", omit_tools_from: ["code_mode"] }, + { url: "https://coordinator.example/mcp" }, + ]; + for (const transport of transports) { + for (const selected of [["peer_ping"], ["absent"]]) { + expect(overrides(transport, selected)).toEqual({ + mcp_servers: { + [owner]: { omit_tools_from: ["code_mode", "deferred"] }, + }, + }); + } + } + expect( + overrides({ command: "coordinator", omit_tools_from: ["direct"] }, [ + "absent", + ]), + ).toEqual({}); +}); + test("native MCP registrations take precedence over the selected plugin's names and opt-outs", () => { const owner = "codex-security"; const alias = "codex.security"; From a379f43325725ab5d27e8a8f7d56957b0e5af415 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:40:19 +0000 Subject: [PATCH 11/13] fix: preserve excluded MCP namespaces during Deep Scan --- sdk/typescript/src/deep-scan.ts | 15 ++++++++------- .../api-deep-scan-runtime-routing.test.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index 8487857b7c..a977d6bf82 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -182,17 +182,18 @@ export function deepScanParentToolOverrides( `mcp_servers.${owner}.omit_tools_from=${inlineToml([...new Set([...surfaces, "code_mode", "deferred"])])}`, ]; } - // Move only this raw server into a free namespace. Preserve an explicit - // naming policy and avoid making the other server inherit a direct slot. + const excluded = isRecord(mode) ? mode["excluded_tool_namespaces"] : null; + const routed = Array.isArray(excluded) ? [...direct, ...excluded] : direct; + // Moving the owner also renames its peers. Keep names that the caller + // explicitly routed or excluded from Code Mode. if ( current === bare || occupied.has(bare) || - direct.includes(current) || - // Moving the owner invalidates an explicitly routed, disambiguated peer. - direct.some( + routed.some( (name) => - typeof name === "string" && - /^mcp__codex_security_[0-9a-f]{12}$/u.test(name), + name === current || + (typeof name === "string" && + /^mcp__codex_security_[0-9a-f]{12}$/u.test(name)), ) || (option != null && !enabled) ) diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 1627ccbd40..a5f1bc0fb5 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -88,7 +88,14 @@ test("a colliding MCP server leaves only the original coordinator's namespace di expect(overrides(false)).toBeUndefined(); expect(overrides(true)).toBeUndefined(); expect(overrides(undefined, [prefixed])).toBeUndefined(); - expect(overrides(undefined, [`${prefixed}_9721dc6aa9d2`])).toBeUndefined(); + const guardedPeer = `${prefixed}_9721dc6aa9d2`; + expect(overrides(undefined, [guardedPeer])).toBeUndefined(); + expect( + deepScanParentToolOverrides( + { features: { code_mode: { excluded_tool_namespaces: [guardedPeer] } } }, + servers, + ), + ).toEqual([]); expect( overrides(undefined, [], { ...servers, [owner]: { enabled: false } }), ).toBeUndefined(); From f746ecdb3b44f83d234f463c916815c45fccaaa4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:52:07 +0000 Subject: [PATCH 12/13] fix: preserve inherited Deep Scan tool omissions --- sdk/typescript/src/deep-scan.ts | 42 +++++++++++-------- .../api-deep-scan-runtime-routing.test.ts | 30 ++++++++++--- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index a977d6bf82..e9f449c220 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -9,6 +9,7 @@ import { inlineToml, type JsonObject } from "./config.js"; import { isRecord } from "./record.js"; import { executablePathForSpawn, + PLUGIN_NAME, runCodexCommand, type CodexCommand, } from "./runtime.js"; @@ -53,7 +54,12 @@ function suppliedParentRouting( routing.set(key, override); } try { - return parse([...routing.values()].join("\n")) as JsonObject; + const config = parse([...routing.values()].join("\n")) as JsonObject; + const servers = config["mcp_servers"]; + const owner = isRecord(servers) ? servers[PLUGIN_NAME] : undefined; + // Even a supplied transport can inherit a machine-level direct opt-out. + if (isRecord(owner) && !Array.isArray(owner["omit_tools_from"])) return; + return config; } catch { // Leave overlapping or malformed TOML for exec's own parser. return undefined; @@ -127,7 +133,7 @@ export function deepScanParentToolOverrides( (isRecord(mode) ? mode["direct_only_tool_namespaces"] : undefined) ?? []; if (!Array.isArray(direct)) return []; - const owner = "codex-security"; + const owner = PLUGIN_NAME; const bare = "codex_security"; const prefixed = "mcp__codex_security"; let required = [prefixed, bare]; @@ -142,6 +148,22 @@ export function deepScanParentToolOverrides( if (!isRecord(ownerConfig) || ownerConfig["enabled"] === false) return []; const omitted = ownerConfig["omit_tools_from"]; if (Array.isArray(omitted) && omitted.includes("direct")) return []; + // Select an explicitly configured transport by raw ID, preserving the + // caller's namespace lists. A plugin-only registration owns its transport. + if ( + isRecord(configured) && + Object.hasOwn(configured, owner) && + (typeof ownerConfig["command"] === "string" || + typeof ownerConfig["url"] === "string") + ) { + if (omitted != null && !Array.isArray(omitted)) return []; + const surfaces = omitted ?? []; + if (surfaces.includes("code_mode") && surfaces.includes("deferred")) + return []; + return [ + `mcp_servers.${owner}.omit_tools_from=${inlineToml([...new Set([...surfaces, "code_mode", "deferred"])])}`, + ]; + } const option = features["non_prefixed_mcp_tool_names"]; const enabled = option === true || (isRecord(option) && option["enabled"] === true); @@ -166,22 +188,6 @@ export function deepScanParentToolOverrides( ); const current = namespace(owner); if (occupied.has(current)) { - // A configured transport can select the raw server without renaming peers. - // Adding this leaf to a plugin-only registration would shadow its transport. - if ( - isRecord(configured) && - Object.hasOwn(configured, owner) && - (typeof ownerConfig["command"] === "string" || - typeof ownerConfig["url"] === "string") - ) { - if (omitted != null && !Array.isArray(omitted)) return []; - const surfaces = omitted ?? []; - if (surfaces.includes("code_mode") && surfaces.includes("deferred")) - return []; - return [ - `mcp_servers.${owner}.omit_tools_from=${inlineToml([...new Set([...surfaces, "code_mode", "deferred"])])}`, - ]; - } const excluded = isRecord(mode) ? mode["excluded_tool_namespaces"] : null; const routed = Array.isArray(excluded) ? [...direct, ...excluded] : direct; // Moving the owner also renames its peers. Keep names that the caller diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index a5f1bc0fb5..92e86a17aa 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -426,15 +426,21 @@ test.each([ `${scenario.route} route reached`, ); expect(started).toHaveLength(1); - const overrides = started[0]!.configOverrides?.filter((value) => - value.startsWith("features.code_mode.direct_only_tool_namespaces="), + const rawRule = + 'mcp_servers.codex-security.omit_tools_from=["code_mode","deferred"]'; + const overrides = started[0]!.configOverrides?.filter( + (value) => + value === rawRule || + value.startsWith("features.code_mode.direct_only_tool_namespaces="), ); expect(overrides).toEqual( scenario.route === "parent" && typeof codeMode !== "string" && !scenario.directOmitted ? [ - 'features.code_mode.direct_only_tool_namespaces=["existing_tools","mcp__codex_security","codex_security"]', + scenario.configuredOmit !== undefined + ? rawRule + : 'features.code_mode.direct_only_tool_namespaces=["existing_tools","mcp__codex_security","codex_security"]', ] : [], ); @@ -457,9 +463,12 @@ test("older app-servers retain supplied routing without starting in the caller p join(home, "config.toml"), '[features.code_mode]\ndirect_only_tool_namespaces = ["inherited_tools"]\n', ); - const read = (config: CodexOptions["config"]) => + const read = ( + config: CodexOptions["config"], + configOverrides: string[] = [], + ) => readDeepScanParentConfig({ - codexOptions: { config, env: { CODEX_HOME: home } }, + codexOptions: { config, configOverrides, env: { CODEX_HOME: home } }, preflightCommand: resolveCodexCommand({}), scanDir, signal: new AbortController().signal, @@ -480,6 +489,17 @@ test("older app-servers retain supplied routing without starting in the caller p expect(await read({ "features.code_mode": "invalid" })).toEqual({ features: { code_mode: "invalid" }, }); + const owner = { command: "fixture" }; + const explicit = { mcp_servers: { "codex-security": owner } }; + expect(await read(explicit)).toBeUndefined(); + expect( + await read(explicit, [ + 'mcp_servers.codex-security.omit_tools_from=["direct"]', + "mcp_servers.codex-security.omit_tools_from=[]", + ]), + ).toEqual({ + mcp_servers: { "codex-security": { ...owner, omit_tools_from: [] } }, + }); }); test("a capable app-server launches from the caller and reads the scan settings and selected plugin", async () => { From 4d9154c69a9f9bb0af1e37d67a4fe7043adb7291 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 25 Oct 2026 22:55:37 +0000 Subject: [PATCH 13/13] fix: retain native routing when the caller supplies only MCP omissions --- sdk/typescript/src/deep-scan.ts | 7 ++++--- .../tests-ts/api-deep-scan-runtime-routing.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index e9f449c220..37a60ba42f 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -148,12 +148,13 @@ export function deepScanParentToolOverrides( if (!isRecord(ownerConfig) || ownerConfig["enabled"] === false) return []; const omitted = ownerConfig["omit_tools_from"]; if (Array.isArray(omitted) && omitted.includes("direct")) return []; - // Select an explicitly configured transport by raw ID, preserving the - // caller's namespace lists. A plugin-only registration owns its transport. + // A raw transport or an existing omission leaf can be updated in place. + // Adding a new registration would shadow a plugin's transport and policy. if ( isRecord(configured) && Object.hasOwn(configured, owner) && - (typeof ownerConfig["command"] === "string" || + (Array.isArray(omitted) || + typeof ownerConfig["command"] === "string" || typeof ownerConfig["url"] === "string") ) { if (omitted != null && !Array.isArray(omitted)) return []; diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index 92e86a17aa..9d45d7eb58 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -500,6 +500,12 @@ test("older app-servers retain supplied routing without starting in the caller p ).toEqual({ mcp_servers: { "codex-security": { ...owner, omit_tools_from: [] } }, }); + const leaf = await read({}, [ + "mcp_servers.codex-security.omit_tools_from=[]", + ]); + expect(deepScanParentToolOverrides(leaf!, {})).toEqual([ + 'mcp_servers.codex-security.omit_tools_from=["code_mode","deferred"]', + ]); }); test("a capable app-server launches from the caller and reads the scan settings and selected plugin", async () => {