Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions docs/project-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,25 @@ If a runtime aliases the ambient TOML file, only explicit overrides are written
back; unrelated sections and inherited defaults remain untouched. Isolated runtime
files still receive a complete snapshot.

Optional discovery model settings are available in the legacy user
`$CODEX_HOME/codex-security/config.toml` file (`~/.codex` when `CODEX_HOME` is unset):

```toml
[deep_scan]
discovery_model = "your-discovery-model"
discovery_reasoning_effort = "high"
```

Each omitted setting inherits the parent scan's choice. Setup and reducer workers
keep the parent settings. Discovery choices are saved when the scan starts and
retained when workers resume or the scan is rerun. SDK callers can set
`discoveryModel` and `discoveryReasoningEffort` directly; each optional string
overrides its legacy TOML value. These settings have no new CLI flags or
project-file keys. Supplying all six orchestration settings forms a complete
configuration that does not read legacy TOML; include any desired discovery
overrides explicitly. Saved recipes preserve the original choices without reading
today's legacy configuration.

A valid deep block can stay inactive in standard mode. Explicit deep CLI options
still require deep mode. Deep diff scans and custom validation remain unsupported.
Counts retain their existing bounds; zero subagents is valid, and discovery time
Expand Down
4 changes: 4 additions & 0 deletions plugins/codex-security/mcp-app/src/deep-scan/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export function startDeepScanEngine(options: {
executor: new CodexSdkWorkerExecutor({
model: options.model,
reasoningEffort: options.reasoningEffort,
discovery: {
model: run.config.discoveryModel,
reasoningEffort: run.config.discoveryReasoningEffort,
},
parentSandbox: options.parentSandbox,
artifactContext: {
pluginRoot,
Expand Down
17 changes: 11 additions & 6 deletions plugins/codex-security/mcp-app/src/deep-scan/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import type {
export interface CodexSdkWorkerModelSettings {
model?: string;
reasoningEffort?: string;
discovery?: {
model?: string;
reasoningEffort?: string;
};
artifactContext?: CodexSdkWorkerArtifactContext;
parentSandbox?: DeepWorkerParentSandbox;
}
Expand Down Expand Up @@ -121,13 +125,16 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
// Keep host-bound inputs beside the prompt, outside the worker's output root.
await writePrivateFile(includePathsFile, scopeInput);
}
const discovery =
request.kind === "discovery" ? this.modelSettings.discovery : undefined;
const model = discovery?.model ?? this.modelSettings.model;
const reasoningEffort =
discovery?.reasoningEffort ?? this.modelSettings.reasoningEffort;
// Keep one native configuration for the policy check and the worker turn.
// Worker-owned tool and permission settings take precedence over inheritance.
const configOverrides = profileConfigOverrides({
...runtimeSettings.config,
...(this.modelSettings.reasoningEffort
? { model_reasoning_effort: this.modelSettings.reasoningEffort }
: {}),
...(reasoningEffort ? { model_reasoning_effort: reasoningEffort } : {}),
mcp_servers: {
// A disabled server still needs a valid transport during native resolution.
"codex-security": { command: "node", enabled: false },
Expand Down Expand Up @@ -187,9 +194,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
profileName: runtimeSettings.nativeProfile,
});
const threadOptions = {
...(this.modelSettings.model
? { model: this.modelSettings.model }
: {}),
...(model ? { model } : {}),
threadSource: "security_scan",
skipGitRepoCheck: true,
workingDirectory: request.workingDirectory,
Expand Down
27 changes: 21 additions & 6 deletions plugins/codex-security/mcp-app/src/deep-scan/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const responseObject = <T extends z.ZodRawShape>(shape: T) =>
return value;
});
const optionalString = optionalValue(requiredString);
const optionalConfiguredString = requiredString
.refine((value) => value.trim().length > 0)
.nullish();

export const workerSchema = responseObject({
id: requiredString,
Expand Down Expand Up @@ -50,13 +53,25 @@ export const configSchema = z
stopAfterConsecutiveErrors: positiveInteger.nullish(),
maxDiscoveryRuns: positiveInteger,
maxTimeHours: z.number().positive().max(96).optional(),
discoveryModel: optionalConfiguredString,
discoveryReasoningEffort: optionalConfiguredString,
})
.transform(({ stopAfterConsecutiveErrors, maxTimeHours, ...config }) => ({
...config,
stopAfterConsecutiveErrors:
stopAfterConsecutiveErrors ?? config.stopAfterNoNew,
...(maxTimeHours === undefined ? {} : { maxTimeHours }),
}));
.transform(
({
stopAfterConsecutiveErrors,
maxTimeHours,
discoveryModel,
discoveryReasoningEffort,
...config
}) => ({
...config,
stopAfterConsecutiveErrors:
stopAfterConsecutiveErrors ?? config.stopAfterNoNew,
...(maxTimeHours === undefined ? {} : { maxTimeHours }),
...(discoveryModel == null ? {} : { discoveryModel }),
...(discoveryReasoningEffort == null ? {} : { discoveryReasoningEffort }),
}),
);

// Check the config object before status, then its fields before the run fields.
export const runSchema = z
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-security/mcp-app/src/workbench/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export function applyMigrations(
);
} else if (item.version === 16) backfill = repairStableTargets(database);
else if (applied.has(item.version)) {
if ([2, 12, 13, 26, 28, 31, 32, 47].includes(item.version))
if ([2, 12, 13, 26, 28, 31, 32, 47, 50].includes(item.version))
repairAdditive(database, item.version);
else if (item.version === 11) repairDeepScan(database);
} else {
Expand Down
38 changes: 26 additions & 12 deletions plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1446,9 +1446,21 @@ wire_api = "responses"
env_key = "SYNTHETIC_GATEWAY_KEY"`,
);
const settings = [
{ model: "gpt-5.6-sol", reasoningEffort: "xhigh" },
{ model: "gpt-6-astra", reasoningEffort: "ultra" },
{ model: "gpt-6.1-sol", reasoningEffort: "max" },
{
model: "gpt-5.6-sol",
reasoningEffort: "xhigh",
discovery: { model: "gpt-5.6-luna", reasoningEffort: "medium" },
},
{
model: "gpt-6-astra",
reasoningEffort: "ultra",
discovery: { model: "fixture-discovery-model" },
},
{
model: "gpt-6.1-sol",
reasoningEffort: "max",
discovery: { reasoningEffort: "fixture-future-effort" },
},
{ model: "gpt-6-sol", reasoningEffort: "high" },
{ model: "fixture-future-model", reasoningEffort: "future-effort" },
// Omitted settings preserve the model and effort in the Codex home.
Expand Down Expand Up @@ -1859,7 +1871,7 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`,
}),
);
// A running coordinator retains its settings if the source file changes.
for (const kind of ["discovery", "dedup"] as const) {
for (const kind of ["discovery", "dedup", "setup"] as const) {
for (const resumeThreadId of [undefined, "fixture-resumed-thread"]) {
launches.length = 0;
await Promise.all(
Expand Down Expand Up @@ -1922,14 +1934,16 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`,
({ args }) => args[0] === "exec",
);
assert.equal(workerLaunches.length, settings.length);
for (const [
index,
{ model, reasoningEffort },
] of settings.entries()) {
const workerLaunch = workerLaunches.find(({ args }) =>
model === undefined
? !args.includes("--model")
: args[args.indexOf("--model") + 1] === model,
for (const [index, parent] of settings.entries()) {
const discovery =
kind === "discovery" ? parent.discovery : undefined;
const model = discovery?.model ?? parent.model;
const reasoningEffort =
discovery?.reasoningEffort ?? parent.reasoningEffort;
const workerLaunch = workerLaunches.find(
({ environment }) =>
environment!.CODEX_SECURITY_CONFIG_PATH ===
workerConfigurations[index].path,
);
assert.ok(workerLaunch, `missing worker launch for ${model}`);
assert.equal(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ async function testDeepScanStdioLifecycle() {
`.deep-scan-stdio-test-${randomUUID()}.cjs`,
);
const threadId = "deep-scan-stdio-lifecycle-thread";
const deepConfigPath = path.join(codexHome, "codex-security", "config.toml");
const deepConfig = `[deep_scan]
workers = 1
subagents = 0
stop_after_no_new = 1
max_discovery_runs = 2
`;

await mkdir(targetPath, { recursive: true });
await mkdir(failedTargetPath, { recursive: true });
Expand Down Expand Up @@ -111,12 +118,9 @@ async function testDeepScanStdioLifecycle() {
"print('failure fixture')\n",
);
await writeFile(
path.join(codexHome, "codex-security", "config.toml"),
`[deep_scan]
workers = 1
subagents = 0
stop_after_no_new = 1
max_discovery_runs = 2
deepConfigPath,
`${deepConfig}discovery_model = "gpt-5.6-luna"
discovery_reasoning_effort = "medium"
`,
);
await writeFakeCodex(fakeCodexPath);
Expand Down Expand Up @@ -273,9 +277,11 @@ model_reasoning_summary = "none"
),
{ code: "ENOENT" },
);
assertFlagPair(startedWorker.argv, "--model", "gpt-5.5");
assert.equal(startedState.config.discoveryModel, "gpt-5.6-luna");
assert.equal(startedState.config.discoveryReasoningEffort, "medium");
assertFlagPair(startedWorker.argv, "--model", "gpt-5.6-luna");
assert.equal(
startedWorker.argv.includes('model_reasoning_effort="xhigh"'),
startedWorker.argv.includes('model_reasoning_effort="medium"'),
true,
);
assert.equal(
Expand Down Expand Up @@ -486,6 +492,7 @@ model_reasoning_summary = "none"
assertCanceled(lateJoin, scanId, startedState.scanDir);
assert.equal((await readLogLines(startLogPath)).length, 1);

await writeFile(deepConfigPath, deepConfig);
const failureThreadId = "deep-scan-stdio-failure-thread";
server.sendRequest(
20,
Expand Down Expand Up @@ -662,6 +669,12 @@ model_reasoning_summary = "none"
"the MCP server must remain responsive after canceling one scan",
);

await writeFile(
deepConfigPath,
`${deepConfig}discovery_model = "fixture-resumed-discovery-model"
discovery_reasoning_effort = "low"
`,
);
const resumedThreadId = "deep-scan-stdio-resumed-thread";
const opened = await server.request(
24,
Expand Down Expand Up @@ -812,6 +825,12 @@ model_reasoning_summary = "none"
resumedScanId,
]);
await writeFile(restartControlPath, "after-restart");
await writeFile(
deepConfigPath,
`${deepConfig}discovery_model = "fixture-unrelated-discovery-model"
discovery_reasoning_effort = "minimal"
`,
);

const resumedStartIndex = (await readJsonLines(startLogPath)).length;
const restartedServer = startServer(serverBundlePath, environment);
Expand Down Expand Up @@ -970,11 +989,15 @@ model_reasoning_summary = "none"
assertFlagPair(
execution.argv,
"--model",
afterRestart ? "gpt-6.1-sol" : "gpt-5.6-sol",
context.workerLabel
? "fixture-resumed-discovery-model"
: afterRestart
? "gpt-6.1-sol"
: "gpt-5.6-sol",
);
assert.ok(
execution.argv.includes(
`model_reasoning_effort=${JSON.stringify(afterRestart ? "max" : "high")}`,
`model_reasoning_effort=${JSON.stringify(context.workerLabel ? "low" : afterRestart ? "max" : "high")}`,
),
);

Expand All @@ -994,6 +1017,7 @@ model_reasoning_summary = "none"
} finally {
await restartedServer.stop();
}
await writeFile(deepConfigPath, deepConfig);
await testCliDeepScanEngine({
fixtureRoot,
environment,
Expand Down
28 changes: 28 additions & 0 deletions plugins/codex-security/mcp-app/tests/test_deep_scan_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ await testTerminalProtocol();
testRunErrorParsing();
testLegacyResponseNormalization();
testConfiguredMaximumDurationParsing();
testDiscoveryModelSettingsParsing();
await testWriteSerializationAndRecovery();
await testBeginUsesTheWriteQueue();
await testHeartbeatBypassesBlockedWriteQueue();
Expand Down Expand Up @@ -971,6 +972,33 @@ function testConfiguredMaximumDurationParsing() {
assert.equal(Object.hasOwn(legacyState.config, "maxTimeHours"), false);
}

function testDiscoveryModelSettingsParsing() {
for (const field of ["discoveryModel", "discoveryReasoningEffort"] as const) {
for (const value of [undefined, null, "custom-value", " custom-value "]) {
const state = parseDeepScan(
stateResult(randomUUID(), {
deepScan: { config: { [field]: value } },
}),
);
assert.equal(state.config[field], value ?? undefined);
assert.equal(Object.hasOwn(state.config, field), value != null);
}
for (const value of ["", " ", false, 12, [], {}]) {
assert.throws(
() =>
parseDeepScan(
stateResult(randomUUID(), {
deepScan: { config: { [field]: value } },
}),
),
{
message: `Codex Security workbench returned invalid deepScan.config.${field}.`,
},
);
}
}
}

function testRunErrorParsing() {
const createdAt = "2026-08-12T19:00:00Z";
const state = parseDeepScan(
Expand Down
15 changes: 13 additions & 2 deletions plugins/codex-security/mcp-app/tests/test_workbench_database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ test("recorded additive migrations restore missing columns and configured error
database.exec(`INSERT INTO deep_scan_runs (scan_id, schema_version, workflow_version, status, phase,
workers, subagents, stop_after_no_new, max_discovery_runs, created_at, updated_at)
VALUES ('scan', 1, 'v1', 'running', 'discovery', 1, 0, 7, 10, 'created', 'updated')`);
for (const version of [27, 28, 31, 32, 47])
for (const version of [27, 28, 31, 32, 47, 50])
database
.prepare("INSERT INTO schema_migrations VALUES (?, ?, 'original')")
.run(version, migrations[version - 1].name);
Expand All @@ -528,8 +528,18 @@ test("recorded additive migrations restore missing columns and configured error
.get()?.discovery_user_context_json,
null,
);
const discoverySettings = () =>
Object.values(
database
.prepare(
"SELECT discovery_model, discovery_reasoning_effort FROM deep_scan_runs",
)
.get()!,
);
assert.deepEqual(discoverySettings(), [null, null]);
database.exec(`UPDATE deep_scan_runs SET stop_after_consecutive_errors = 2,
discovery_user_context_json = '"Original discovery context"'`);
discovery_user_context_json = '"Original discovery context"',
discovery_model = 'fixture-model', discovery_reasoning_effort = 'custom-effort'`);
applyMigrations(database);
assert.equal(
database
Expand All @@ -543,6 +553,7 @@ test("recorded additive migrations restore missing columns and configured error
.get()?.discovery_user_context_json,
'"Original discovery context"',
);
assert.deepEqual(discoverySettings(), ["fixture-model", "custom-effort"]);
});

test("workflow and review checkpoints upgrade atomically with their preserved result data", (t) => {
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-security/references/scan-prologue.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ A top-level Standard scan reads `scan-runtime.md` once after its authoritative s

## Deep Worker Failures

Deep Security Scan uses MCP-owned SDK sessions rather than the parent thread's worker pool. Its preflight does not require a particular parent delegation runtime, ownership, capacity, or depth. Discovery workers inherit the scan's model and use the reserved `codex_security_deep_scan_worker` permission profile with the parent's supported filesystem denials. The selected Codex executable must support permission-profile configuration and allowance checks. If that command fails to start or exits early, report its path and the tool's diagnostic; do not infer that its version is unsupported. If the tool identifies a missing API, ask the user to update the Codex installation at the reported path: the desktop app for an app-bundled executable, or the selected CLI otherwise. If Codex policy rejects the profile, report the tool's administrator guidance. Do not remove deny rules or select a broader sandbox to work around the error.
Deep Security Scan uses MCP-owned SDK sessions rather than the parent thread's worker pool. Its preflight does not require a particular parent delegation runtime, ownership, capacity, or depth. Discovery workers inherit the scan's model and reasoning effort unless the user configures `discovery_model` or `discovery_reasoning_effort` in `[deep_scan]`; setup and reducer workers retain the parent settings. Workers use the reserved `codex_security_deep_scan_worker` permission profile with the parent's supported filesystem denials. The selected Codex executable must support permission-profile configuration and allowance checks. If that command fails to start or exits early, report its path and the tool's diagnostic; do not infer that its version is unsupported. If the tool identifies a missing API, ask the user to update the Codex installation at the reported path: the desktop app for an app-bundled executable, or the selected CLI otherwise. If Codex policy rejects the profile, report the tool's administrator guidance. Do not remove deny rules or select a broader sandbox to work around the error.

Deep Scan workers use the selected Codex home's credentials and provider configuration. An existing `CODEX_API_KEY` remains selected. When the worker has no Codex account and its provider requires OpenAI authentication, it can use `OPENAI_API_KEY` through the SDK's API-key option. This does not replace a stored account, custom-provider authentication, or a forced ChatGPT login. The key must be present in the process that launched the plugin; setting it later in a separate shell does not update a running plugin.

Expand Down
Loading
Loading