Skip to content
Draft
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ npm install @openai/codex-security
npx @openai/codex-security login
npx @openai/codex-security scan .
npx @openai/codex-security scan . --model gpt-5.6-terra --effort high
npx @openai/codex-security scan . --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10
```

For CI, set `OPENAI_API_KEY` or `CODEX_API_KEY` instead of signing in. Environment API keys are
Expand Down Expand Up @@ -58,6 +59,13 @@ import { CodexSecurity } from "@openai/codex-security";

const security = new CodexSecurity();
const result = await security.run(".");
await security.run(".", {
mode: "deep",
workers: 2,
subagents: 0,
stopAfterNoNew: 3,
maxDiscoveryRuns: 10,
});

console.log(result.reportPath);
await security.close();
Expand Down
32 changes: 32 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/r
npx @openai/codex-security scan /path/to/repository --dry-run
npx @openai/codex-security scan /path/to/repository --fail-on-severity high
npx @openai/codex-security scan /path/to/repository --max-cost 5
npx @openai/codex-security scan /path/to/repository --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10
npx @openai/codex-security install-hook
npx @openai/codex-security bulk-scan
npx @openai/codex-security bulk-scan --model gpt-5.6-terra --effort high
Expand Down Expand Up @@ -224,6 +225,37 @@ to
Repeat `--knowledge-base PATH` for multiple files or directories. Directories are
searched recursively for Markdown, text, PDF, and Word (`.docx`) files.

### Configure deep scans

For `scan --mode deep`, `--workers` limits concurrent discovery workers,
`--subagents` controls each worker's subagents, `--stop-after-no-new` stops after
that many runs find no new issues, and `--max-discovery-runs` limits total runs.
These options are also available on SDK scans:

```ts
await security.run("/path/to/repository", {
mode: "deep",
workers: 2,
subagents: 0,
stopAfterNoNew: 3,
maxDiscoveryRuns: 10,
});
```

Set defaults in `~/.codex/codex-security/config.toml`, or under `$CODEX_HOME`
when it is configured. Explicit CLI and SDK settings override these defaults:

```toml
[deep_scan]
workers = 2
subagents = 0
stop_after_no_new = 3
max_discovery_runs = 10
```

`scan --workers` controls discovery workers within one deep scan;
`bulk-scan --workers` controls how many repositories are scanned concurrently.

On macOS/Linux, an existing output directory must be private to the current
user (`chmod 700`).

Expand Down
24 changes: 24 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,30 @@ def begin_deep_scan_for_scan(
args: argparse.Namespace,
) -> dict[str, Any]:
scan_id = require_uuid(scan_id, "scan-id")
candidate = require_scan(connection, scan_id)
workspace = require_workspace(connection, candidate["workspace_id"])
if (
candidate["mode"] == "deep"
and candidate["status"] == "running"
and candidate["recipe_json"] is not None
and candidate["handoff_status"] == "delivered"
and candidate["deep_scan_owner_thread_id"] is None
and workspace["thread_id"] is None
):
timestamp = now()
with connection:
claimed_workspace = connection.execute(
"UPDATE workspaces SET thread_id = ?, updated_at = ? "
"WHERE id = ? AND thread_id IS NULL",
(thread_id, timestamp, workspace["id"]),
)
claimed_scan = connection.execute(
"UPDATE scans SET deep_scan_owner_thread_id = ?, updated_at = ? "
"WHERE id = ? AND deep_scan_owner_thread_id IS NULL",
(thread_id, timestamp, scan_id),
)
if claimed_workspace.rowcount != 1 or claimed_scan.rowcount != 1:
raise SystemExit("A scan can only be orchestrated from its owning Codex thread.")
scan, _ = require_owned_scan(connection, scan_id, thread_id)
require_current_continuation(
scan,
Expand Down
117 changes: 114 additions & 3 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
/// <reference lib="esnext.disposable" preserve="true" />

import { chmod, lstat, mkdir, realpath, rm, writeFile } from "node:fs/promises";
import {
chmod,
lstat,
mkdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
import { Codex, type CodexOptions } from "@openai/codex-sdk";
import {
parse as parseToml,
stringify as stringifyToml,
type TomlTable,
} from "smol-toml";
import {
accountStatus,
CodexLoginHandle,
Expand Down Expand Up @@ -119,7 +132,14 @@ interface PreparedRuntime {
effectiveConfig?: JsonObject;
}

export interface ScanOptions {
export interface DeepScanOptions {
workers?: number;
subagents?: number;
stopAfterNoNew?: number;
maxDiscoveryRuns?: number;
}

export interface ScanOptions extends DeepScanOptions {
auth?: ScanAuthMode;
target?: ScanTarget;
mode?: ScanMode;
Expand Down Expand Up @@ -174,7 +194,7 @@ type ScanObserverName =
| "onWorkerStatus"
| "onWarning";

export interface ScanPreflight {
export interface ScanPreflight extends DeepScanOptions {
repository: string;
target: NormalizedTarget;
mode: ScanMode;
Expand Down Expand Up @@ -219,6 +239,12 @@ const DEFAULT_DEPENDENCIES: ClientDependencies = {
};

const SCAN_PERMISSION_PROFILE = "codex_security_scan";
const DEEP_SCAN_SETTINGS = [
["workers", "workers", 1],
["subagents", "subagents", 0],
["stopAfterNoNew", "stop_after_no_new", 1],
["maxDiscoveryRuns", "max_discovery_runs", 1],
] as const;

export class CodexSecurity {
public readonly config: Readonly<CodexSecurityConfig>;
Expand Down Expand Up @@ -282,6 +308,7 @@ export class CodexSecurity {
repository: inputs.repository,
target: inputs.target,
mode: inputs.mode,
...deepScanOptions(options),
...(options.knowledgeBasePaths?.length
? { knowledgeBasePaths: options.knowledgeBasePaths }
: {}),
Expand Down Expand Up @@ -409,6 +436,14 @@ export class CodexSecurity {
}
const runtimeHome = await realpath(runtime.codexHome);
requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime");
if (mode === "deep") {
await prepareDeepScanConfig(
runtimeHome,
this.#dependencies.environment,
options,
signal,
);
}
if (
options.expectedPluginVersion !== undefined &&
runtime.plugin.version !== options.expectedPluginVersion
Expand Down Expand Up @@ -579,6 +614,7 @@ export class CodexSecurity {
options.failureSeverity,
knowledgeBase?.sources,
options.maxCostUsd,
deepScanOptions(options),
);
const workbenchOptions: WorkbenchCommandOptions = {
python,
Expand Down Expand Up @@ -1200,6 +1236,7 @@ export class CodexSecurity {
options: ScanOptions,
signal?: AbortSignal,
): Promise<LocalScanInputs> {
deepScanOptions(options);
if (
options.maxCostUsd !== undefined &&
(!Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0)
Expand Down Expand Up @@ -1340,6 +1377,71 @@ export class CodexSecurity {
}
}

function deepScanOptions(options: ScanOptions): DeepScanOptions {
const selected: DeepScanOptions = {};
for (const [name, , minimum] of DEEP_SCAN_SETTINGS) {
const value = options[name];
if (value === undefined) continue;
if ((options.mode ?? "standard") !== "deep") {
throw new CodexSecurityError("Deep scan settings require deep mode.");
}
if (!Number.isSafeInteger(value) || value < minimum) {
throw new CodexSecurityError(
`Deep scan ${name} must be ${minimum === 0 ? "a non-negative" : "a positive"} integer.`,
);
}
selected[name] = value;
}
return selected;
}

async function prepareDeepScanConfig(
codexHome: string,
environment: ProcessEnvironment,
options: DeepScanOptions,
signal: AbortSignal,
): Promise<void> {
const ambientHome =
environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex");
const source = join(ambientHome, "codex-security", "config.toml");
let configured: TomlTable = {};
try {
configured = parseToml(
await readFile(source, { encoding: "utf8", signal }),
);
} catch (error) {
if (!isRecord(error) || error["code"] !== "ENOENT") {
throw new CodexSecurityError(
`Cannot read Codex Security configuration at ${source}.`,
{ cause: error },
);
}
}
const existing = configured["deep_scan"];
if (existing !== undefined && !isRecord(existing)) {
throw new CodexSecurityError(
`Codex Security configuration [deep_scan] at ${source} must be a TOML table.`,
);
}
const overrides: TomlTable = {};
for (const [name, key] of DEEP_SCAN_SETTINGS) {
const value = options[name];
if (value !== undefined) overrides[key] = value;
}
if (existing === undefined && Object.keys(overrides).length === 0) return;
const destination = join(codexHome, "codex-security", "config.toml");
if (destination === source && Object.keys(overrides).length === 0) return;
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
await writeFile(
destination,
stringifyToml({
...configured,
deep_scan: { ...existing, ...overrides },
}),
{ mode: 0o600, signal },
);
}

export async function initialCredentialsAvailable(
environment: ProcessEnvironment,
ambientHome: string,
Expand Down Expand Up @@ -1552,6 +1654,11 @@ async function scanPrompt(
return [
`Use the installed $codex-security:${skillName} skill at "$CODEX_SECURITY_PLUGIN_ROOT/skills/${skillName}/SKILL.md".`,
"Run this Codex Security scan non-interactively.",
...(mode === "deep"
? [
'The SDK has already registered this scan. Call start_codex_security_deep_scan with { scanId: "$CODEX_SECURITY_SCAN_ID" }; never pass targetPath or create another scan.',
]
: []),
...(skillName === "deep-security-scan"
? []
: [
Expand Down Expand Up @@ -1618,6 +1725,7 @@ function scanRecipe(
failOnSeverity?: SeverityLevel,
knowledgeBasePaths?: string[],
maxCostUsd?: number,
deepScan?: DeepScanOptions,
): JsonObject {
return {
repository,
Expand All @@ -1636,6 +1744,9 @@ function scanRecipe(
...(failOnSeverity === undefined ? {} : { failOnSeverity }),
...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }),
...(maxCostUsd === undefined ? {} : { maxCostUsd }),
...(deepScan === undefined || Object.keys(deepScan).length === 0
? {}
: { deepScan: { ...deepScan } }),
};
}

Expand Down
Loading
Loading