Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a147e46
fix(cost): enforce verifiable scan spending limits
mldangelo-oai Aug 15, 2026
95b3782
fix(cost): reject incomplete final session usage
mldangelo-oai Aug 15, 2026
ce460fe
fix(cost): verify delegated worker completion and ownership
mldangelo-oai Aug 15, 2026
23e2682
Merge commit '5d1afcd312933121e36dc892dcb12e8d2e3e1de3' into mdangelo…
mldangelo-oai Aug 16, 2026
55f73bd
fix(cost): require completed root evidence for scan budgets
mldangelo-oai Aug 16, 2026
1f072db
fix(cost): preserve definitive budget overages
mldangelo-oai Aug 16, 2026
830b696
fix(api): retain definitive budget failure classification
mldangelo-oai Aug 16, 2026
f04345e
fix(cost): retain overages discovered during cleanup
mldangelo-oai Aug 16, 2026
b9f37a9
fix(cost): retain highest usage through budget cleanup
mldangelo-oai Aug 16, 2026
9eb6500
fix(cost): retain verifiable per-session accounting
mldangelo-oai Aug 16, 2026
11a702e
fix(cost): respect proven worker ownership
mldangelo-oai Aug 16, 2026
b2fdfa4
fix(cost): exclude unrelated historical accounting evidence
mldangelo-oai Aug 16, 2026
ff1827d
fix(cost): reverify late final accounting
mldangelo-oai Aug 16, 2026
59b2598
fix(cost): retain fresh usage from missing-session reads
mldangelo-oai Aug 16, 2026
4c6c807
fix(cost): preserve unpriced completed usage
mldangelo-oai Aug 16, 2026
b6f45b6
fix(cost): preserve finalization and usage boundaries
mldangelo-oai Aug 16, 2026
392a195
fix(cost): reject unusable owned accounting
mldangelo-oai Aug 16, 2026
4e5e34c
fix(cost): accumulate reset-aware session usage
mldangelo-oai Aug 16, 2026
4443643
Merge main into verifiable scan budget repair
mldangelo-oai Aug 17, 2026
7eaa2f1
fix(cost): preserve final accounting and worker shutdown
mldangelo-oai Aug 17, 2026
8e21385
Merge main session setup into scan budget repair
mldangelo-oai Aug 17, 2026
3c25018
fix(sdk): exclude internal helpers from public declarations
mldangelo-oai Aug 17, 2026
88b0933
fix(cost): align whole-counter reset accounting
mldangelo-oai Aug 18, 2026
9aec155
Merge main into reset-aligned scan accounting
mldangelo-oai Aug 18, 2026
cc91460
fix(cost): exclude inherited fork usage
mldangelo-oai Aug 18, 2026
0859c3f
fix(cost): preserve full UUIDv7 fork ordering
mldangelo-oai Aug 24, 2026
0ba0c69
fix(cost): reject incomplete worker session metadata
mldangelo-oai Aug 25, 2026
046c7ef
Attribute incomplete rollouts through trusted session ownership
mldangelo-oai Aug 25, 2026
5f0baaf
Verify ownership of every unidentified budgeted rollout
mldangelo-oai Aug 25, 2026
cb6b36b
Account for equal-total token counter resets
mldangelo-oai Aug 25, 2026
6bca8b1
Verify complete scan accounting before budget finalization
mldangelo-oai Aug 25, 2026
b4406c7
Attribute fork-only workers during live budget tracking
mldangelo-oai Aug 25, 2026
0846e25
chore: reconcile scan budgets with main
mldangelo-oai Aug 26, 2026
962a6b7
fix: avoid double-counting custom validation usage
mldangelo-oai Aug 26, 2026
5ef6dd2
refactor: merge main and simplify scan accounting fixtures
mldangelo-oai Aug 27, 2026
250acf1
fix(cost): refresh the cached accounting plugin
mldangelo-oai Aug 27, 2026
488c4dd
chore: merge final release packaging updates
mldangelo-oai Aug 27, 2026
aa383f5
Merge main into verifiable scan budgets
mldangelo-oai Aug 31, 2026
47a2862
chore: resolve PR #465 conflicts with main
mldangelo-oai Sep 3, 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
2 changes: 1 addition & 1 deletion plugins/codex-security/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-security",
"version": "0.1.94",
"version": "0.1.99",
"description": "Codex Security workflows for security scans, analysis, and investigation.",
"author": {
"name": "OpenAI"
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-security/mcp-app/src/deep-scan/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor {
} else if (event.type === "turn.completed") {
turnCompleted = true;
request.signal.removeEventListener("abort", forwardAbort);
break;
// Drain the SDK stream so the worker can flush its session records.
} else if (event.type === "turn.failed") {
throw new Error(event.error.message);
} else if (event.type === "error") {
Expand Down
18 changes: 10 additions & 8 deletions plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ try {
await testSandboxNamespaceDiagnosticIsSanitized();
await testOwnedArtifactToolFailureDiagnosticIsSanitized();
await testStreamTerminationWithoutTerminalEventFails();
await testCompletedWorkerSettlesWithoutWaitingForProcessExit();
await testCompletedWorkerFlushesBeforeSettling();
await testAbortPropagation();
await testConfigurationFailureIsNonRetryable();
await testThreadStartConfigurationFailureIsNonRetryable();
Expand Down Expand Up @@ -1005,7 +1005,7 @@ async function testAbortPropagation() {
}
}

async function testCompletedWorkerSettlesWithoutWaitingForProcessExit() {
async function testCompletedWorkerFlushesBeforeSettling() {
const fixture = await fakeCodexFixture();
const previousPath = process.env.CODEX_CLI_PATH;
process.env.CODEX_CLI_PATH = fixture.executablePath;
Expand All @@ -1021,7 +1021,7 @@ async function testCompletedWorkerSettlesWithoutWaitingForProcessExit() {
const promptPath = path.join(fixture.root, "prompt.md");
const workingDirectory = path.join(fixture.root, "artifacts");
await mkdir(workingDirectory);
await writeFile(promptPath, "COMPLETE_THEN_HANG\n");
await writeFile(promptPath, "COMPLETE_THEN_FLUSH\n");
execution = new CodexSdkWorkerExecutor({
parentSandbox: trustedParentSandbox
}).run({
Expand All @@ -1037,14 +1037,15 @@ async function testCompletedWorkerSettlesWithoutWaitingForProcessExit() {
new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort("completed worker fixture timed out");
reject(new Error("completed worker did not settle after turn.completed"));
reject(new Error("completed worker did not settle after flushing usage"));
}, 1_000);
})
]);
clearTimeout(timeout);
assert.equal(result.threadId, "fixture-thread-id");
assert.equal(result.finalResponse, "fixture final response");
childPid = JSON.parse(await readFile(fixture.markerPath, "utf8")).pid;
assert.equal(await readFile(fixture.completionMarkerPath, "utf8"), "flushed\n");

controller.abort("coordinator immediately canceled its remaining workers");
await new Promise((resolve) => setTimeout(resolve, 250));
Expand Down Expand Up @@ -1337,6 +1338,7 @@ async function fakeCodexFixture(
const root = await mkdtemp(path.join(tmpdir(), "codex-security-sdk-executor-"));
temporaryRoots.push(root);
const markerPath = path.join(root, "invocation.json");
const completionMarkerPath = path.join(root, "completed-usage.txt");
const preflightMarkerPath = path.join(root, "preflight.json");
const scriptPath = path.join(root, "fake-codex.mjs");
await writeFile(scriptPath, [
Expand All @@ -1345,6 +1347,7 @@ async function fakeCodexFixture(
`const preflightProfile = ${JSON.stringify(preflightProfile)};`,
`const preflightAllowed = ${JSON.stringify(preflightAllowed)};`,
`const preflightMarkerPath = ${JSON.stringify(preflightMarkerPath)};`,
`const completionMarkerPath = ${JSON.stringify(completionMarkerPath)};`,
"if (process.argv.includes('app-server')) {",
" const preflight = { cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };",
" writeFileSync(preflightMarkerPath, JSON.stringify(preflight));",
Expand Down Expand Up @@ -1385,8 +1388,7 @@ async function fakeCodexFixture(
"let stdin = '';",
"for await (const chunk of process.stdin) stdin += chunk;",
"const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;",
"writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));",
"if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));",
"writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_FLUSH') ? { pid: process.pid } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));",
"if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }",
"if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }",
"if (stdin.includes('MCP_STARTUP_TIMEOUT') || stdin.includes('CATALOG_AUTH_ONLY') || stdin.includes('SYNC_AUTH_ONLY')) {",
Expand Down Expand Up @@ -1426,13 +1428,13 @@ async function fakeCodexFixture(
"}",
"console.log(JSON.stringify({ type: 'item.completed', item: { id: 'message-1', type: 'agent_message', text: 'fixture final response' } }));",
"console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));",
"if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await new Promise(() => {}); }",
"if (stdin.includes('COMPLETE_THEN_FLUSH')) { await new Promise((resolve) => setTimeout(resolve, 100)); writeFileSync(completionMarkerPath, 'flushed\\n'); }",
"}",
""
].join("\n"));
await chmod(scriptPath, 0o755);
process.env.FAKE_CODEX_MARKER = markerPath;
return { root, markerPath, preflightMarkerPath, executablePath: scriptPath };
return { root, markerPath, completionMarkerPath, preflightMarkerPath, executablePath: scriptPath };
}

function assertFlagPair(args, flag, value) {
Expand Down
26 changes: 19 additions & 7 deletions plugins/codex-security/scripts/workbench_scan_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,17 +456,29 @@ def _read_rollout_usage(
if timestamp is None or snapshot is None:
warnings.add("token_record_invalid")
continue
reset = snapshot["totalTokens"] < previous["totalTokens"]
delta = {
key: value - previous[key] if value >= previous[key] else value
key: value if reset or value < previous[key] else value - previous[key]
for key, value in snapshot.items()
}
previous = snapshot
if timestamp < started_at:
continue
if completed_at is not None and timestamp > completed_at:
continue
delta["totalTokens"] = delta["inputTokens"] + delta["outputTokens"]
Comment thread
mldangelo-oai marked this conversation as resolved.
if delta["totalTokens"] <= 0:
continue
delta["cacheWriteInputTokens"] = min(
delta["cacheWriteInputTokens"], delta["inputTokens"]
)
delta["cachedInputTokens"] = min(
delta["cachedInputTokens"],
delta["inputTokens"] - delta["cacheWriteInputTokens"],
)
delta["reasoningOutputTokens"] = min(
delta["reasoningOutputTokens"], delta["outputTokens"]
)
_add_token_usage(total, delta)

if not boundary_reached:
Expand Down Expand Up @@ -501,19 +513,19 @@ def _is_owned_task_start(
turn_id = payload.get("turn_id")
if not isinstance(turn_id, str) or not turn_id:
return False
thread_timestamp = _uuid7_timestamp(thread_id)
turn_timestamp = _uuid7_timestamp(turn_id)
if thread_timestamp is None:
thread_order = _uuid7_order(thread_id)
turn_order = _uuid7_order(turn_id)
if thread_order is None:
return True
return turn_timestamp is not None and turn_timestamp >= thread_timestamp
return turn_order is not None and turn_order >= thread_order


def _uuid7_timestamp(value: str) -> int | None:
def _uuid7_order(value: str) -> int | None:
try:
parsed = uuid.UUID(value)
except ValueError:
return None
return parsed.int >> 80 if parsed.version == 7 else None
return parsed.int if parsed.version == 7 else None


def _token_snapshot(payload: Mapping[str, Any]) -> dict[str, int] | None:
Expand Down
107 changes: 89 additions & 18 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ import {
resolveCodexCommand,
resolvePluginPath,
resolvePluginPython,
resolveScanSessionPaths,
runWorkbench,
setCodexSecurityCredentialLogout,
type CodexCommand,
Expand Down Expand Up @@ -410,6 +411,7 @@ interface ClientDependencies {
signal?: AbortSignal,
) => Promise<PreparedRuntime>;
resolvePluginPython?: typeof resolvePluginPython;
resolveScanSessionPaths?: typeof resolveScanSessionPaths;
prepareOutputDir?: typeof prepareOutputDir;
prepareScanArtifactRestorer?: typeof prepareScanArtifactRestorer;
repositoryRevision?: typeof repositoryRevision;
Expand Down Expand Up @@ -789,6 +791,7 @@ export class CodexSecurity {
let scanFailure = false;
let customValidationComplete = false;
let completionCost: ScanCost | null = null;
let signaledCostUsage: unknown;
let budgetRecovery: {
expectation: ScanExpectation;
pluginRoot: string;
Expand Down Expand Up @@ -968,8 +971,8 @@ export class CodexSecurity {
pluginVersion: runtime.plugin.version,
};
const { model } = scanModelConfiguration(effectiveConfig);
validateScanCostLimit(options.maxCostUsd, model);
if (mode === "deep" && options.maxCostUsd !== undefined) {
validateScanCostLimit(maxCostUsd, model);
if (mode === "deep" && maxCostUsd !== undefined) {
budgetRecovery = {
expectation,
pluginRoot: runtime.plugin.installedRoot,
Expand All @@ -996,7 +999,7 @@ export class CodexSecurity {
);
};
const reportTrackingError = (error: unknown): void => {
if (options.maxCostUsd !== undefined) {
if (maxCostUsd !== undefined) {
costAbortController.abort(error);
return;
}
Expand All @@ -1012,7 +1015,22 @@ export class CodexSecurity {
model,
repository: repo,
scanDirectory: scanDir,
maxCostUsd: options.maxCostUsd,
maxCostUsd: maxCostUsd,
resolveOwnedSessionPaths:
maxCostUsd === undefined
? undefined
: async (threadId) => {
const scan = activeScan;
if (scan === null) {
throw new CodexSecurityError(
"The scan session ownership could not be verified.",
);
}
return await (
this.#dependencies.resolveScanSessionPaths ??
resolveScanSessionPaths
)(scan.options, scan.id, threadId);
},
onActivity:
options.onActivity === undefined
? undefined
Expand All @@ -1036,9 +1054,9 @@ export class CodexSecurity {
onProgress:
options.onProgress === undefined ? undefined : reportProgress,
onCost:
options.onCost === undefined && options.maxCostUsd === undefined
options.onCost === undefined && maxCostUsd === undefined
? undefined
: (cost) => {
: (cost, usage) => {
latestCost = cost;
notifyObserver(
"onCost",
Expand All @@ -1049,8 +1067,10 @@ export class CodexSecurity {
);
if (
maxCostUsd !== undefined &&
cost.estimatedUsd > maxCostUsd
cost.estimatedUsd > maxCostUsd &&
!costAbortController.signal.aborted
) {
signaledCostUsage = usage;
costAbortController.abort(
new ScanCostLimitExceededError(maxCostUsd, cost, scanDir),
);
Expand Down Expand Up @@ -1101,6 +1121,7 @@ export class CodexSecurity {
);
if (budgetSignal.aborted) return;
maxCostUsd = next;
tracker.setMaxCostUsd(next);
notifyObserver(
"onCost",
options.onCost,
Expand Down Expand Up @@ -1132,7 +1153,7 @@ export class CodexSecurity {
{ ...preflightConfig, approval_policy: approvalPolicy },
options.failureSeverity,
knowledgeBase?.sources,
options.maxCostUsd,
maxCostUsd,
deepScanOptions(options),
);
if (options.validationPrompt !== undefined)
Expand Down Expand Up @@ -1290,7 +1311,7 @@ export class CodexSecurity {
runtime.configPath !== undefined,
knowledgeBase !== null,
options.scanPrompt,
options.maxCostUsd !== undefined,
maxCostUsd !== undefined,
discoveryPrompt,
);
checkOpen();
Expand Down Expand Up @@ -1447,6 +1468,8 @@ export class CodexSecurity {
}
},
onFinalize: async (usage) => {
// Validation threads are accounted separately from the discovery turn.
const discoveryUsage = usage;
if (options.validationPrompt !== undefined) {
tracker.recordUsage(usage);
await tracker.refresh().catch(reportTrackingError);
Expand Down Expand Up @@ -1506,13 +1529,24 @@ export class CodexSecurity {
customValidationComplete = true;
}
budgetAbortController.abort();
const snapshot = await tracker.stop(usage).catch((error: unknown) => {
if (options.maxCostUsd !== undefined) throw error;
reportTrackingError(error);
return { usage, cost: estimateScanCost(model, usage) };
});
const snapshot = await tracker
.stop(discoveryUsage)
.catch(async (error: unknown) => {
if (maxCostUsd !== undefined) {
throwIfAborted(signal, scanDir);
try {
return await tracker.stop(discoveryUsage);
} catch {
runPostScan = null;
throwIfAborted(signal, scanDir);
throw error;
}
}
reportTrackingError(error);
return { usage, cost: estimateScanCost(model, usage) };
});
throwIfAborted(signal, scanDir);
if (options.maxCostUsd !== undefined && snapshot.cost === null) {
if (maxCostUsd !== undefined && snapshot.cost === null) {
notifyObserver(
"onWarning",
options.onWarning,
Expand Down Expand Up @@ -1733,11 +1767,48 @@ export class CodexSecurity {
// Recorded first: everything below can throw a different error for this same failed
// scan, and cleanup must treat all of those as a failure it is not allowed to mask.
scanFailure = true;
const snapshot = await costTracker?.stop().catch(() => null);
let failure =
const trackedSnapshot = await costTracker?.stop().catch(() => null);
const signaledOverage =
signal.reason instanceof ScanCostLimitExceededError
? signal.reason
: error;
: null;
const trackedCost = trackedSnapshot?.cost;
const snapshot =
signaledOverage !== null &&
(trackedCost === undefined ||
trackedCost === null ||
signaledOverage.cost.estimatedUsd > trackedCost.estimatedUsd)
? {
cost: signaledOverage.cost,
usage: signaledCostUsage ?? {
input_tokens: signaledOverage.cost.inputTokens,
cached_input_tokens: signaledOverage.cost.cachedInputTokens,
cache_write_input_tokens:
signaledOverage.cost.cacheWriteInputTokens,
output_tokens: signaledOverage.cost.outputTokens,
reasoning_output_tokens: 0,
},
}
: trackedSnapshot;
const knownCost = snapshot?.cost;
let failure: unknown = signaledOverage ?? error;
if (
maxCostUsd !== undefined &&
knownCost !== undefined &&
knownCost !== null &&
knownCost.estimatedUsd > maxCostUsd &&
(signaledOverage !== null ||
(!this.#abortController.signal.aborted &&
options.signal?.aborted !== true)) &&
(signaledOverage === null ||
knownCost.estimatedUsd > signaledOverage.cost.estimatedUsd)
) {
failure = new ScanCostLimitExceededError(
maxCostUsd,
knownCost,
scanDir,
);
}
if (
failure instanceof ScanCostLimitExceededError &&
snapshot?.cost &&
Expand Down
Loading
Loading