Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
adc9031
refactor: share Codex construction and stream reduction
mldangelo-oai Sep 12, 2026
036dfa4
test: preserve the Node runtime location in worker fixtures
mldangelo-oai Sep 12, 2026
f6f99f9
test: match Windows managed child executable paths
mldangelo-oai Sep 12, 2026
1f16e85
test: resolve shared SDK imports in MCP source fixtures
mldangelo-oai Sep 12, 2026
2135d4c
fix: resolve shared Codex imports in standalone MCP builds
mldangelo-oai Sep 12, 2026
0a37fd0
Merge main into shared execution
mldangelo-oai Sep 16, 2026
6e425e4
Use the Codex client directly at execution boundaries
mldangelo-oai Sep 16, 2026
3bbd4bd
Format Codex SDK import
mldangelo-oai Sep 16, 2026
e230677
Test worker shutdown at the real child-process boundary
mldangelo-oai Sep 16, 2026
3808311
Preserve resolved provider settings in scan workers
mldangelo-oai Sep 16, 2026
4ff8103
Pin worker provider and authentication selections per session
mldangelo-oai Sep 16, 2026
0b43c3f
Resolve shared config dependency in standalone MCP builds
mldangelo-oai Sep 16, 2026
8d1d8f9
Keep only the selected provider in worker configuration
mldangelo-oai Sep 16, 2026
f34a767
Preserve Python discovery in installed Deep Scan fixtures
mldangelo-oai Sep 16, 2026
2877c44
Compare selected fixture Python by executable identity
mldangelo-oai Sep 16, 2026
d4a007b
Protect per-session worker configuration from model tools
Sep 17, 2026
5783953
Preserve literal worker denials and use runtime executor settings
mldangelo-oai Sep 17, 2026
b80972f
Preserve literal and glob worker denials with identical paths
mldangelo-oai Sep 17, 2026
a82da86
Wait for reconstructed worker fixtures to close before cleanup
mldangelo-oai Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function buildMcpApp({ output }) {
loader: { ".md": "text" },
logLevel: "info",
logOverride: { "empty-import-meta": "silent" },
nodePaths: [join(root, "node_modules")],
outfile: bundle,
platform: "node",
target: "node20"
Expand Down
177 changes: 103 additions & 74 deletions plugins/codex-security/mcp-app/src/deep-scan/executor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { accessSync, constants as fsConstants, existsSync, promises as fs, readdirSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { Codex } from "@openai/codex-sdk";
import { delimiter, dirname, isAbsolute, join, parse, resolve, win32 } from "node:path";
import {
readCodexSessionTurn
} from "../../../../../sdk/typescript/src/codex-session.js";
import { Codex, type CodexOptions } from "@openai/codex-sdk";
import {
codexWorkerConfig, codexWorkerConfigPath, inlineToml, modelProviderConfigOverride,
type JsonObject
} from "../../../../../sdk/typescript/src/config.js";
import { parse as parseToml } from "smol-toml";
import { executablePathForSpawn } from "./executable-path.js";
import {
Expand Down Expand Up @@ -39,7 +46,7 @@ export interface CodexSdkWorkerArtifactContext {
}

export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
private runtimeReasoningSummary?: Promise<string | undefined>;
private runtimeModelConfig?: Promise<NonNullable<CodexOptions["config"]>>;

constructor(private readonly modelSettings: CodexSdkWorkerModelSettings = {}) {}

Expand All @@ -52,11 +59,23 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
);
}
const workerProfile = workerPermissionProfile(parentSandbox);
const configOverrides = workerPermissionProfileConfigOverrides(workerProfile);
const originalCwd = process.cwd();
const childEnv = await snapshotWorkerEnvironment();
// Snapshot the SDK's per-scan config once for this coordinator, including resumes.
const reasoningSummary = await (this.runtimeReasoningSummary ??= workerReasoningSummary(childEnv));
// Cache per-scan selections; reconstructed workers reload the same file.
// Native account credentials continue to refresh in the selected home.
const modelConfig: NonNullable<CodexOptions["config"]> = {
...await (this.runtimeModelConfig ??= workerModelConfig(childEnv)),
...(this.modelSettings.model ? { model: this.modelSettings.model } : {}),
// The CLI can add effort levels before the pinned SDK widens ThreadOptions.
...(this.modelSettings.reasoningEffort
? { model_reasoning_effort: this.modelSettings.reasoningEffort }
: {})
};
const { model_providers: _providers, ...sdkModelConfig } = modelConfig;
const configOverrides = [
...modelProviderConfigOverride(modelConfig as JsonObject),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep provider secrets out of worker arguments

When the selected custom provider contains a literal experimental_bearer_token or authorization header, modelProviderConfigOverride serializes the full provider definition into the Codex worker's command-line arguments. On Linux, repository-controlled worker tools running with the configured root-read profile can inspect the ancestor command line through /proc or process-listing tools and return the credential in a model request. Although this head now denies access to the snapshot file, the new provider tests confirm these secret fields still survive into configOverrides, leaving this separate disclosure path; pass secret material through a mechanism unavailable to sandboxed tools rather than argv.

AGENTS.md reference: sdk/typescript/AGENTS.md:L8-L12

Useful? React with 👍 / 👎.

...workerPermissionProfileConfigOverrides(workerProfile)
];
const openAiApiKey = environmentVariable(childEnv, "OPENAI_API_KEY", process.platform)?.trim();
const codexApiKey = environmentVariable(childEnv, "CODEX_API_KEY", process.platform)?.trim();
const codexPath = resolveCodexPath(
Expand All @@ -69,7 +88,13 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
codexPath,
cwd: request.workingDirectory,
profileId: DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID,
configOverrides,
configOverrides: [
...Object.entries(codexWorkerConfig(modelConfig as JsonObject))
// Older SDK/direct-plugin workers keep their home-selected provider.
.filter(([key]) => key !== "model_providers" && modelConfig[key] !== undefined)
.map(([key, value]) => `${key}=${inlineToml(value)}`),
...configOverrides
],
expectedProfile: workerProfile,
env: childEnv,
allowOpenAiApiKeyFallback: Boolean(openAiApiKey && !codexApiKey),
Expand All @@ -83,13 +108,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
// Keep native credentials unless the worker has no configured account.
...(useOpenAiApiKey ? { apiKey: openAiApiKey } : {}),
config: {
...(reasoningSummary === undefined
? {}
: { model_reasoning_summary: reasoningSummary }),
// The CLI can add effort levels before the pinned SDK widens ThreadOptions.
...(this.modelSettings.reasoningEffort
? { model_reasoning_effort: this.modelSettings.reasoningEffort }
: {}),
...sdkModelConfig,
mcp_servers: {
// Discovery workers use the bundled skills and artifacts, not the parent workbench MCP.
// A disabled server still needs a valid transport while Codex resolves plugin configuration.
Expand Down Expand Up @@ -125,51 +144,44 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {

try {
const { events } = await thread.runStreamed(input, { signal: controller.signal });
let finalResponse = "";
let threadId: string | undefined;
let turnCompleted = false;
let lastStreamError: string | undefined;
const diagnostics: CodexWorkerDiagnostic[] = [];
for await (const event of events) {
if (event.type === "thread.started") {
threadId = event.thread_id;
await request.onThreadStarted?.(threadId);
} else if (event.type === "item.completed") {
const fallbackError = event.item.type === "error"
? deepScanPermissionProfileFallbackError(event.item.message)
: undefined;
if (fallbackError) {
controller.abort(fallbackError);
throw fallbackError;
}
if (event.item.type === "agent_message") {
finalResponse = event.item.text;
} else {
const turn = await readCodexSessionTurn({
thread,
events,
stopOnCompletion: true,
onEvent: async (event) => {
if (event.type === "thread.started" && typeof event.thread_id === "string") {
await request.onThreadStarted?.(event.thread_id);
} else if (event.type === "item.completed" && isRecord(event.item)) {
const fallbackError = event.item.type === "error" && typeof event.item.message === "string"
? deepScanPermissionProfileFallbackError(event.item.message)
: undefined;
if (fallbackError) {
controller.abort(fallbackError);
throw fallbackError;
}
appendSafeItemDiagnostic(diagnostics, event.item);
} else if (event.type === "turn.completed") {
request.signal.removeEventListener("abort", forwardAbort);
} else if (event.type === "turn.failed") {
throw new Error((event.error as { message: string }).message);
} else if (event.type === "error" && typeof event.message === "string") {
const fallbackError = deepScanPermissionProfileFallbackError(event.message);
if (fallbackError) {
controller.abort(fallbackError);
throw fallbackError;
}
// Codex exec emits retry-in-progress notifications as error events.
}
} else if (event.type === "turn.completed") {
turnCompleted = true;
request.signal.removeEventListener("abort", forwardAbort);
break;
} else if (event.type === "turn.failed") {
throw new Error(event.error.message);
} else if (event.type === "error") {
const fallbackError = deepScanPermissionProfileFallbackError(event.message);
if (fallbackError) {
controller.abort(fallbackError);
throw fallbackError;
}
// Codex exec currently emits retry-in-progress notifications as error events.
lastStreamError = event.message;
}
}
if (!turnCompleted) {
const detail = lastStreamError ? `: ${lastStreamError}` : "";
});
if (turn.status !== "completed") {
const detail = turn.lastStreamError ? `: ${turn.lastStreamError}` : "";
throw new Error(`Codex worker stream ended before turn.completed${detail}`);
}
return {
finalResponse,
threadId: threadId ?? thread.id ?? undefined,
finalResponse: turn.finalResponse,
threadId: turn.threadId ?? thread.id ?? undefined,
...(diagnostics.length > 0 ? { diagnostics } : {})
};
} finally {
Expand Down Expand Up @@ -251,7 +263,9 @@ function workerSubagentConfig(subagents: number) {
// V1 counts children; V2 counts the root plus its children. Keeping its
// feature disabled lets the model choose either runtime without rejecting
// inherited agents.max_threads configuration.
...(subagents > 0 ? { agents: { max_threads: subagents } } : {}),
...(subagents > 0
? { agents: { max_threads: subagents } }
: {}),
features: {
multi_agent_v2: {
enabled: false,
Expand All @@ -274,17 +288,33 @@ type TomlObject = { [key: string]: TomlValue };
function workerPermissionProfile(
sandbox: DeepWorkerParentSandbox
): TomlObject {
const filesystemEntries: Array<[string, TomlValue]> = [[":root", "read"]];
const seenFilesystemKeys = new Set<string>();
const filesystemEntries = new Map<string, TomlValue>([[":root", "read"]]);
const literalPaths = new Set(sandbox.filesystemDenies.flatMap(
(denial) => typeof denial === "string" ? [] : [denial.path]
));
const collidingGlobs = new Set<string>();

for (const denial of sandbox.filesystemDenies) {
const key = typeof denial === "string" ? denial : denial.path;
if (typeof denial === "string" && literalPaths.has(key)) {
collidingGlobs.add(key);
} else {
filesystemEntries.set(key, typeof denial === "string" ? "deny" : { ".": "deny" });
}
}

for (const key of sandbox.filesystemDenies) {
if (seenFilesystemKeys.has(key)) continue;
seenFilesystemKeys.add(key);
filesystemEntries.push([key, "deny"]);
// Scoped glob keys keep both meanings without duplicate filesystem TOML keys.
for (const pattern of collidingGlobs) {
const root = parse(pattern).root;
const scope = filesystemEntries.get(root);
filesystemEntries.set(root, {
...(scope === undefined ? {} : typeof scope === "object" ? scope : { ".": scope }),
[pattern.slice(root.length)]: "deny"
});
}

if (sandbox.globScanMaxDepth !== undefined) {
filesystemEntries.push(["glob_scan_max_depth", sandbox.globScanMaxDepth]);
filesystemEntries.set("glob_scan_max_depth", sandbox.globScanMaxDepth);
}

return {
Expand Down Expand Up @@ -383,30 +413,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

async function workerReasoningSummary(environment: Record<string, string>): Promise<string | undefined> {
async function workerModelConfig(environment: Record<string, string>): Promise<NonNullable<CodexOptions["config"]>> {
const configPath = environmentVariable(environment, "CODEX_SECURITY_CONFIG_PATH", process.platform);
if (!configPath) return undefined;
const config = parseToml(await fs.readFile(configPath, "utf8"));
const profiles = config.profiles;
const profile = typeof config.profile === "string" && isRecord(profiles)
? profiles[config.profile]
: undefined;
const summary = isRecord(profile) && profile.model_reasoning_summary !== undefined
? profile.model_reasoning_summary
: config.model_reasoning_summary;
return typeof summary === "string" ? summary : undefined;
if (!configPath) return {};
try {
return codexWorkerConfig(parseToml(await fs.readFile(codexWorkerConfigPath(configPath), "utf8")) as JsonObject) as NonNullable<CodexOptions["config"]>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
// Older SDKs only provide preflight input. Keep their home-selected provider.
const config = codexWorkerConfig(parseToml(await fs.readFile(configPath, "utf8")) as JsonObject) as NonNullable<CodexOptions["config"]>;
return config.model_reasoning_summary === undefined ? {} : { model_reasoning_summary: config.model_reasoning_summary };
}

async function snapshotWorkerEnvironment(): Promise<Record<string, string>> {
async function snapshotWorkerEnvironment(source: NodeJS.ProcessEnv = process.env): Promise<Record<string, string>> {
const environment = Object.fromEntries(
Object.entries(process.env)
Object.entries(source)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
) as Record<string, string>;
if (process.platform === "win32") {
// process.env is case-insensitive on Windows; a plain object is not.
// Keep its selected values while giving the child one spelling per key.
for (const name of ["CODEX_CLI_PATH", "CODEX_HOME", "CODEX_MANAGED_PACKAGE_ROOT", "LOCALAPPDATA"]) {
const value = process.env[name];
const value = environmentVariable(source, name, process.platform);
for (const key of Object.keys(environment)) {
if (key.toUpperCase() === name) delete environment[key];
}
Expand Down
14 changes: 5 additions & 9 deletions plugins/codex-security/mcp-app/src/deep-scan/parent-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ export const CODEX_SANDBOX_STATE_META_CAPABILITY = "codex/sandbox-state-meta";
export type DeepWorkerParentSandbox = {
/**
* Validated path and glob keys copied into the worker's stricter root-read
* profile. Grants are intentionally not transported because Deep Scan
* profile. Objects retain literal paths containing glob characters.
* Grants are intentionally not transported because Deep Scan
* workers never inherit parent write access.
*/
readonly filesystemDenies: readonly string[];
readonly filesystemDenies: readonly (string | { readonly path: string })[];
readonly globScanMaxDepth?: number;
};

Expand Down Expand Up @@ -47,7 +48,7 @@ export function resolveDeepWorkerParentSandbox(extra: unknown): DeepWorkerParent
const globScanMaxDepth = resolveGlobScanMaxDepth(filesystem);

let hasRootRead = false;
const filesystemDenies: string[] = [];
const filesystemDenies: Array<string | { path: string }> = [];
for (const value of filesystem.entries) {
const entry = record(value);
if (!entry || !isKnownFilesystemAccess(entry.access)) {
Expand Down Expand Up @@ -89,12 +90,7 @@ export function resolveDeepWorkerParentSandbox(extra: unknown): DeepWorkerParent
"a parent filesystem denial path cannot be preserved"
);
}
if (hasGlobMetacharacters(path.path)) {
throw unsupportedParentSandbox(
"a parent filesystem denial path with glob characters cannot be preserved"
);
}
filesystemDenies.push(path.path);
filesystemDenies.push(hasGlobMetacharacters(path.path) ? { path: path.path } : path.path);
}
} else if (path.type === "glob_pattern") {
if (!isNonEmptyString(path.pattern)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ try {
await writeFile(path.join(repository, "example.py"), "value = 1\n");
await build({
bundle: true,
nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))],
define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" },
entryPoints: [path.join(applicationRoot, "main.ts")],
external: ["fsevents"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ await fs.mkdir(repository);
await fs.writeFile(path.join(repository, "example.py"), "value = 1\n");
await build({
bundle: true,
nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))],
define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" },
entryPoints: [path.join(applicationRoot, "main.ts")],
external: ["fsevents"], format: "cjs", loader: { ".md": "text" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ async function testReducerWorkerToolList(bundle) {
async function bundleEntrypoint(entrypoint, outfile) {
await build({
bundle: true,
nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))],
define: {
__dirname: JSON.stringify(applicationRoot),
"import.meta.url": "__filename"
Expand Down
Loading
Loading