Skip to content
Merged
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ 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 . --scan-prompt-file scan.md --post-scan-prompt-file follow-up.md
npx @openai/codex-security scan . --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10
npx @openai/codex-security scan . --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10 --max-time-hours 1.5
```

For CI, set `OPENAI_API_KEY` or `CODEX_API_KEY` instead of signing in.
Environment API keys are passed directly to the current scan and are never
stored in Codex's credential home or system keyring.

Deep-scan discovery stops after 96 hours by default. Set `--max-time-hours` to
any positive number of hours, including fractional hours, up to 96. Completed
findings are preserved and returned when the limit is reached.

To use another inference provider, set its API key and select a model:

```bash
Expand Down Expand Up @@ -106,6 +110,7 @@ await security.run(".", {
subagents: 0,
stopAfterNoNew: 3,
maxDiscoveryRuns: 10,
maxTimeHours: 1.5,
});

console.log(result.reportPath);
Expand Down
39 changes: 26 additions & 13 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Pass scan configuration to `security.run(repository, options)` or
| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. |
| `archiveExisting` | Archive results already in `outputDir` before starting a scan. |
| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. |
| `maxTimeHours` | Limit deep-scan discovery to a positive number of hours, up to 96. |
| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. |
| `parentScanId` | Link a rerun to an existing parent scan. |
| `expectedPluginVersion` | Require the original plugin version when replaying a scan. |
Expand Down Expand Up @@ -212,7 +213,7 @@ npx @openai/codex-security scan /path/to/repository --verbose
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 scan /path/to/repository --mode deep --workers 2 --subagents 0 --stop-after-no-new 3 --max-discovery-runs 10 --max-time-hours 1.5
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 @@ -263,8 +264,9 @@ Markdown, text, PDF, and Word (`.docx`) files.

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:
that many runs find no new issues, `--max-discovery-runs` limits total runs, and
`--max-time-hours` limits discovery duration. These options are also available
on SDK scans:

```ts
await security.run("/path/to/repository", {
Expand All @@ -273,6 +275,7 @@ await security.run("/path/to/repository", {
subagents: 0,
stopAfterNoNew: 3,
maxDiscoveryRuns: 10,
maxTimeHours: 1.5,
});
```

Expand All @@ -285,8 +288,15 @@ workers = 2
subagents = 0
stop_after_no_new = 3
max_discovery_runs = 10
max_time_hours = 1.5
```

The discovery deadline defaults to 96 hours. The configured value may be any
positive number, including fractional hours, up to 96. At the deadline,
in-flight discovery stops and completed findings are reduced and returned.
The 97-hour outer tool-call timeout reserves approximately one hour for final
reduction and result delivery, including at the 96-hour maximum.

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

Expand Down Expand Up @@ -382,22 +392,22 @@ workers = "auto"
subagents = 3
stop_after_no_new = 6
max_discovery_runs = 60
max_time_hours = 96
```

`workers = "auto"` uses half the available parallelism, with a minimum of one
and a maximum of six discovery workers. Set `workers` to a positive integer to
choose an explicit count. `subagents` must be a nonnegative integer;
`stop_after_no_new` and `max_discovery_runs` must be positive integers. Unknown
`[deep_scan]` keys are rejected.
`stop_after_no_new` and `max_discovery_runs` must be positive integers.
`max_time_hours` must be a positive finite number no greater than 96; fractional
hours are supported. Unknown `[deep_scan]` keys are rejected.

These settings are separate from Codex's
`features.multi_agent_v2.max_concurrent_threads_per_session` and
`bulk-scan --workers`. Importantly, standalone CLI and SDK scans create an
isolated `CODEX_HOME` and do not import the ambient deep-scan configuration
file. Consequently, `scan --mode deep` currently uses the deep engine's
defaults; there are no standalone CLI flags for these four settings. Use
`--codex` to adjust the Codex session thread limit, not to set `[deep_scan]`
values.
`bulk-scan --workers`. Standalone CLI and SDK scans create an isolated
`CODEX_HOME`, import the ambient `[deep_scan]` configuration, and apply explicit
CLI or SDK options on top. Use `--codex` to adjust the Codex session thread
limit, not to set `[deep_scan]` values.

### Environment variables

Expand Down Expand Up @@ -470,8 +480,11 @@ scan history, and bulk-scan receipt. Estimates use
including cached input and cache writes; fees and surcharges are not included.

Use `--max-cost USD` to stop a scan, including its delegated workers, when its
running cost exceeds the limit. Partial results are preserved. Requests
already in progress can finish above the limit.
running cost exceeds the limit. If a Deep Scan has already finished discovery,
it returns a sealed partial report with any completed findings and lists
unvalidated candidates as follow-up work. Requests already in progress can
finish above the limit; preparing the partial report makes no additional model
requests. Incomplete coverage retains its existing exit code.

Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help`
for the complete CLI references.
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-security",
"version": "0.1.17",
"version": "0.1.19",
"description": "Codex Security workflows for security scans, analysis, and investigation.",
"author": {
"name": "OpenAI"
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/_bundled_plugin/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS"
],
"tool_timeout_sec": 86400
"tool_timeout_sec": 349200
}
}
}
Binary file modified sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000
Binary file not shown.
Binary file modified sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001
Binary file not shown.
22 changes: 21 additions & 1 deletion sdk/typescript/_bundled_plugin/scripts/deep_scan_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import math
import os
from pathlib import Path
from typing import Any
Expand All @@ -16,13 +17,16 @@
DEFAULT_STOP_AFTER_NO_NEW = 6
DEFAULT_STOP_AFTER_CONSECUTIVE_ERRORS = 3
DEFAULT_MAX_DISCOVERY_RUNS = 60
DEFAULT_MAX_TIME_HOURS = 96
MAX_TIME_HOURS = 96
MAX_AUTOMATIC_WORKERS = 6
CONFIG_KEYS = {
"workers",
"subagents",
"stop_after_no_new",
"stop_after_consecutive_errors",
"max_discovery_runs",
"max_time_hours",
}


Expand All @@ -34,7 +38,7 @@ def config_path() -> Path:
return codex_home() / "codex-security" / "config.toml"


def resolve_deep_scan_config(available_parallelism: int) -> dict[str, int]:
def resolve_deep_scan_config(available_parallelism: int) -> dict[str, int | float]:
if isinstance(available_parallelism, bool) or available_parallelism < 1:
raise SystemExit("Available parallelism must be a positive integer.")
path = config_path()
Expand Down Expand Up @@ -87,6 +91,10 @@ def resolve_deep_scan_config(available_parallelism: int) -> dict[str, int]:
"deep_scan.max_discovery_runs",
minimum=1,
),
"maxTimeHours": require_positive_number(
configured.get("max_time_hours", DEFAULT_MAX_TIME_HOURS),
"deep_scan.max_time_hours",
),
}


Expand All @@ -103,6 +111,18 @@ def require_integer(value: object, label: str, *, minimum: int) -> int:
return value


def require_positive_number(value: object, label: str) -> int | float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise SystemExit(f"{label} must be a positive finite number no greater than 96.")
try:
finite = math.isfinite(value)
except OverflowError:
finite = False
if not finite or value <= 0 or value > MAX_TIME_HOURS:
raise SystemExit(f"{label} must be a positive finite number no greater than 96.")
return value


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--available-parallelism", type=int, required=True)
Expand Down
58 changes: 44 additions & 14 deletions sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ def require_deep_scan_run(connection: sqlite3.Connection, scan_id: str) -> sqlit
return row


def deep_scan_deadline_reached(run: sqlite3.Row) -> bool:
elapsed = datetime.fromisoformat(now()) - datetime.fromisoformat(str(run["created_at"]))
return elapsed.total_seconds() / 3600 >= run["max_time_hours"]


def require_deep_scan_ready_for_parent_completion(
connection: sqlite3.Connection, scan: sqlite3.Row
) -> None:
Expand Down Expand Up @@ -361,6 +366,23 @@ def deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, A
""",
(run["scan_id"],),
).fetchone()
canonical_artifacts = None
if run["canonical_inventory_path"] is None:
if successful_reducer is not None:
canonical_artifacts = canonical_discovery_artifacts(scan)
elif (
run["status"] == "succeeded"
and run["terminal_reason"] == "capped"
and run["completion_sequence"] == 0
and deep_scan_deadline_reached(run)
):
candidate_artifacts = canonical_discovery_artifacts(scan)
if Path(candidate_artifacts["candidateLedgerPath"]).stat().st_size != 0:
raise SystemExit(
"A capped Deep Scan without completed discoveries requires an empty "
"candidate ledger."
)
canonical_artifacts = candidate_artifacts
return {
"scanId": run["scan_id"],
"targetPath": scan["target_path"],
Expand All @@ -378,17 +400,14 @@ def deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, A
"stopAfterNoNew": run["stop_after_no_new"],
"stopAfterConsecutiveErrors": run["stop_after_consecutive_errors"],
"maxDiscoveryRuns": run["max_discovery_runs"],
"maxTimeHours": run["max_time_hours"],
},
"dispatchedCount": run["discovery_runs_dispatched"],
"completionSequence": run["completion_sequence"],
"noNewStreak": run["consecutive_no_new"],
"consecutiveErrors": run["consecutive_errors"],
"cancelRequested": bool(run["cancel_requested"]),
"canonicalArtifacts": (
canonical_discovery_artifacts(scan)
if successful_reducer is not None and run["canonical_inventory_path"] is None
else None
),
"canonicalArtifacts": canonical_artifacts,
"manifestPath": run["manifest_path"],
"terminalReason": run["terminal_reason"],
"error": run["error_message"],
Expand Down Expand Up @@ -466,15 +485,15 @@ def deep_scan_result(
return result


def effective_deep_scan_config(args: argparse.Namespace) -> dict[str, int]:
def effective_deep_scan_config(args: argparse.Namespace) -> dict[str, int | float]:
available_parallelism = args.available_parallelism or os.cpu_count() or 1
return resolve_deep_scan_config(available_parallelism)


def ensure_deep_scan_run(
connection: sqlite3.Connection,
scan: sqlite3.Row,
config: dict[str, int],
config: dict[str, int | float],
workflow_version: str,
timestamp: str,
) -> sqlite3.Row:
Expand All @@ -492,9 +511,9 @@ def ensure_deep_scan_run(
INSERT INTO deep_scan_runs (
scan_id, schema_version, workflow_version, status, phase,
workers, subagents, stop_after_no_new, stop_after_consecutive_errors,
max_discovery_runs,
max_discovery_runs, max_time_hours,
created_at, updated_at
) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
scan["id"],
Expand All @@ -504,6 +523,7 @@ def ensure_deep_scan_run(
config["stopAfterNoNew"],
config["stopAfterConsecutiveErrors"],
config["maxDiscoveryRuns"],
config["maxTimeHours"],
timestamp,
timestamp,
),
Expand Down Expand Up @@ -1404,9 +1424,12 @@ def claim_deep_scan_dedup(
"A Deep Scan dedup worker must claim an ordered prefix of buffered discovery "
"results in completion order."
)
hard_cap_singleton = (
capped_singleton = (
len(input_ids) == 1
and run["discovery_runs_dispatched"] >= run["max_discovery_runs"]
and (
run["discovery_runs_dispatched"] >= run["max_discovery_runs"]
or deep_scan_deadline_reached(run)
)
and connection.execute(
"""
SELECT 1 FROM deep_scan_workers
Expand All @@ -1425,7 +1448,7 @@ def claim_deep_scan_dedup(
""",
(scan_id,),
).fetchone()
minimum_inputs = 1 if successful_reducer is not None or hard_cap_singleton else 2
minimum_inputs = 1 if successful_reducer is not None or capped_singleton else 2
if len(input_ids) < minimum_inputs:
raise SystemExit(
"The first Deep Scan dedup requires two buffered discovery results."
Expand Down Expand Up @@ -1679,12 +1702,13 @@ def finish_deep_scan_locked(
if (
args.terminal_reason == "capped"
and run["discovery_runs_dispatched"] < run["max_discovery_runs"]
and not deep_scan_deadline_reached(run)
):
raise SystemExit(
"Deep Scan cannot finish capped before reaching its configured maximum."
)
try:
canonical_discovery_artifacts(scan)
canonical_artifacts = canonical_discovery_artifacts(scan)
except SystemExit as exc:
raise SystemExit(
f"Deep Scan cannot finish without canonical discovery artifacts: {exc}"
Expand All @@ -1697,7 +1721,13 @@ def finish_deep_scan_locked(
""",
(scan_id,),
).fetchone()
if successful_reducer is None:
zero_discovery_deadline = (
args.terminal_reason == "capped"
and deep_scan_deadline_reached(run)
and run["completion_sequence"] == 0
and Path(canonical_artifacts["candidateLedgerPath"]).stat().st_size == 0
)
if successful_reducer is None and not zero_discovery_deadline:
raise SystemExit("Deep Scan cannot finish without a successful dedup worker.")
failed_worker = connection.execute(
"""
Expand Down
Loading
Loading