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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,16 @@ await security.run(".", {
});

console.log(result.reportPath);
console.log(result.rolloutSessionIndexPath);
await security.close();
```

Every started scan indexes its coordinator and descendant worker rollouts in
the private results directory, including failed scans. Raw rollout JSONL files
are copied only when `retainRolloutSessions: true` or the CLI flag
`--retain-rollout-sessions` is set because they can contain source code,
prompts, and tool output.

## Containerized bulk scans

Use the official image and included Docker Compose configuration for
Expand Down
23 changes: 23 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Pass scan configuration to `security.run(repository, options)` or
| `knowledgeBasePaths` | Add architecture documents, security policies, threat models, or directories. |
| `outputDir` | Choose an artifact directory outside the enclosing Git worktree. |
| `archiveExisting` | Archive results already in `outputDir` before starting a scan. |
| `retainRolloutSessions` | Copy coordinator and worker rollout logs into private scan artifacts. |
| `maxCostUsd` | Stop after the estimated model cost exceeds a positive USD amount. |
| `failureSeverity` | Record a finding-severity policy in the saved scan recipe. |
| `parentScanId` | Link a rerun to an existing parent scan. |
Expand Down Expand Up @@ -187,6 +188,7 @@ npx @openai/codex-security scan /path/to/repository --model gpt-5.6-terra
npx @openai/codex-security scan /path/to/repository --model gpt-5.6-terra --effort high
npx @openai/codex-security scan /path/to/repository --path src --path tests
npx @openai/codex-security scan /path/to/repository --knowledge-base /path/to/threat-models --knowledge-base /path/to/architecture.pdf
npx @openai/codex-security scan /path/to/repository --retain-rollout-sessions
npx @openai/codex-security scan /path/to/repository --diff origin/main --json
npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/repository/results
npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/repository/results --archive-existing
Expand Down Expand Up @@ -442,6 +444,27 @@ already in progress can finish above the limit. Cost tracking accepts Codex
session events up to 1 MiB; an oversized event stops the scan because its
running cost can no longer be verified safely.

Each started scan writes
`artifacts/rollout-sessions/index.json` with the coordinator thread, its
transitive worker sessions, and their original rollout locations. Unrelated
sessions in the same Codex home are excluded. The SDK exposes the absolute
location as `result.rolloutSessionIndexPath`, and scan history exposes it for
both completed and failed scans. `complete: false` in the index means the
coordinator rollout was not available when discovery settled. Index creation
has a 4,096-session safety limit so a malformed or unexpectedly shared Codex
home cannot produce an unbounded scan artifact.

When `scan --json` fails after creating an index, its failure object includes
`scanDir` and `rolloutSessionIndexPath` so automation can inspect partial
execution without a separate history query.

Raw rollouts can contain source code, prompts, and tool output, so they are not
copied by default. Pass `--retain-rollout-sessions` or set
`retainRolloutSessions: true` to copy only the indexed coordinator and worker
JSONL files beneath `artifacts/rollout-sessions/sessions/`. The index and
retained files are written with private permissions. Saved scan recipes retain
this choice for `scans rerun`, and bulk scans accept the same CLI flag.

Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help`
for the complete CLI references.

Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/workbench_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def parse_args(description: str) -> argparse.Namespace:
complete_scan.add_argument("--scan-id", required=True)
complete_scan.add_argument("--claim-token")
complete_scan.add_argument("--cost-json")
complete_scan.add_argument("--rollout-session-index-path")

cancel_scan = subparsers.add_parser("cancel-scan")
cancel_scan.add_argument("--scan-id", required=True)
Expand All @@ -228,6 +229,7 @@ def parse_args(description: str) -> argparse.Namespace:
fail_scan.add_argument("--message", required=True)
fail_scan.add_argument("--claim-token")
fail_scan.add_argument("--cost-json")
fail_scan.add_argument("--rollout-session-index-path")

mark_handoff_delivered = subparsers.add_parser("mark-handoff-delivered")
mark_handoff_delivered.add_argument("--scan-id", required=True)
Expand Down
57 changes: 54 additions & 3 deletions sdk/typescript/_bundled_plugin/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1367,9 +1367,17 @@ def complete_scan(
) -> dict[str, Any]:
scan_id = require_uuid(args.scan_id, "scan-id")
cost_json = None if prepare_only else parse_scan_cost(args.cost_json)
rollout_session_index_path = (
None if prepare_only else getattr(args, "rollout_session_index_path", None)
)
with scan_completion_lock(scan_id):
return complete_scan_locked(
connection, scan_id, args.claim_token, cost_json, prepare_only=prepare_only
connection,
scan_id,
args.claim_token,
cost_json,
rollout_session_index_path,
prepare_only=prepare_only,
)


Expand All @@ -1378,6 +1386,7 @@ def complete_scan_locked(
scan_id: str,
claim_token: str | None,
cost_json: str | None,
rollout_session_index_path: str | None,
*,
prepare_only: bool = False,
) -> dict[str, Any]:
Expand Down Expand Up @@ -1411,6 +1420,9 @@ def complete_scan_locked(
if warning is not None and warning not in warnings:
warnings.append(warning)
scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"]))
stored_rollout_session_index_path = scan_relative_artifact_path(
scan_dir, rollout_session_index_path
)
completion_timestamp = now()
completion_binding = workbench_completion_binding(scan, completion_timestamp)
if scan["recipe_json"] is not None:
Expand Down Expand Up @@ -1512,7 +1524,8 @@ def complete_scan_locked(
"""
UPDATE scans
SET status = 'complete', phase = 'reporting', completed_at = ?, updated_at = ?,
seal_manifest_digest = ?, cost_json = ?, completion_warnings_json = ?
seal_manifest_digest = ?, cost_json = ?, completion_warnings_json = ?,
rollout_session_index_path = ?
WHERE id = ? AND status = 'running'
""",
(
Expand All @@ -1521,6 +1534,7 @@ def complete_scan_locked(
manifest_digest,
cost_json,
json.dumps(warnings),
stored_rollout_session_index_path,
scan["id"],
),
)
Expand Down Expand Up @@ -1736,6 +1750,10 @@ def fail_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[
return scan_context(connection, scan["id"])
if scan["status"] == "complete":
raise SystemExit("A completed scan cannot be marked failed.")
scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"]))
rollout_session_index_path = scan_relative_artifact_path(
scan_dir, getattr(args, "rollout_session_index_path", None)
)
handoff.require_current_continuation(
scan,
args.claim_token,
Expand All @@ -1745,14 +1763,15 @@ def fail_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[
"""
UPDATE scans
SET status = 'failed', failure_message = ?, completed_at = ?, updated_at = ?,
cost_json = ?
cost_json = ?, rollout_session_index_path = ?
WHERE id = ? AND status = 'running'
""",
(
optional_text(args.message, maximum=2400),
timestamp,
timestamp,
cost_json,
rollout_session_index_path,
scan["id"],
),
)
Expand Down Expand Up @@ -2949,6 +2968,15 @@ def scan_result(
)
if sarif_path is not None:
artifacts["sarifReport"] = str(sarif_path)
rollout_session_index_path = None
if scan["rollout_session_index_path"] is not None:
rollout_session_index_path = available_artifact_path(
Path(scan["scan_dir"]),
Path(scan["scan_dir"])
/ PurePosixPath(scan["rollout_session_index_path"]),
)
if rollout_session_index_path is not None:
artifacts["rolloutSessionIndex"] = str(rollout_session_index_path)
occurrence_rows = scan_history.finding_occurrence_rows(
connection, scan["id"], offset=0, limit=FINDINGS_RESULT_LIMIT
)
Expand Down Expand Up @@ -3029,6 +3057,11 @@ def scan_result(
"remediationAvailable": remediation_available,
"remediationUnavailableReason": remediation_unavailable_reason,
"reportAvailable": "markdownReport" in artifacts,
"rolloutSessionIndexPath": (
str(rollout_session_index_path)
if rollout_session_index_path is not None
else None
),
"scanDir": scan["scan_dir"],
"scanId": scan["id"],
"scope": scan["scope"],
Expand Down Expand Up @@ -3477,6 +3510,24 @@ def artifact_path(scan_dir: Path, file_name: str, *, required: bool) -> Path | N
return resolved


def scan_relative_artifact_path(scan_dir: Path, value: str | None) -> str | None:
path = optional_text(value, maximum=2048)
if path is None:
return None
parsed = PurePosixPath(path)
if (
parsed.is_absolute()
or "\\" in path
or ".." in parsed.parts
or parsed.as_posix() != path
or not parsed.parts
):
raise SystemExit("Rollout session index path must be scan-relative.")
resolved = artifact_path(scan_dir, path, required=True)
assert resolved is not None
return resolved.relative_to(scan_dir).as_posix()


def require_canonical_scan_directory(scan_dir: Path) -> Path:
scan_dir = scan_dir.absolute()
try:
Expand Down
18 changes: 16 additions & 2 deletions sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ def list_workspace_scans(
).fetchone()[0]
rows = connection.execute(
"""
SELECT id, mode, status, phase, scope, target_revision,
SELECT id, mode, status, phase, scope, target_revision, scan_dir,
seal_manifest_digest, started_at, completed_at, canceled_at,
updated_at, failure_message, completion_warnings_json
updated_at, failure_message, completion_warnings_json,
rollout_session_index_path
FROM scans
WHERE workspace_id = ?
ORDER BY created_at DESC, id DESC
Expand All @@ -106,6 +107,7 @@ def list_workspace_scans(
"mode": row["mode"],
"phase": row["phase"],
"scanId": row["id"],
**rollout_session_index_result(row),
"scope": row["scope"],
"sealed": row["seal_manifest_digest"] is not None,
"startedAt": row["started_at"],
Expand Down Expand Up @@ -240,6 +242,7 @@ def list_scans(
"recipeAvailable": row["recipe_json"] is not None,
"scanDir": row["scan_dir"],
"scanId": row["id"],
**rollout_session_index_result(row),
"scope": row["scope"],
"startedAt": row["started_at"],
"targetId": row["target_id"],
Expand Down Expand Up @@ -267,6 +270,17 @@ def list_scans(
return result


def rollout_session_index_result(scan: sqlite3.Row) -> dict[str, str]:
path = scan["rollout_session_index_path"]
if path is None:
return {}
return {
"rolloutSessionIndexPath": str(
Path(scan["scan_dir"]).joinpath(*PurePosixPath(path).parts)
)
}


def list_unmatched_scan_pairs(
connection: sqlite3.Connection,
args: argparse.Namespace,
Expand Down
7 changes: 7 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/workbench_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,13 @@
ADD COLUMN completion_warnings_json TEXT NOT NULL DEFAULT '[]';
""",
),
(
27,
"persist rollout session indexes",
"""
ALTER TABLE scans ADD COLUMN rollout_session_index_path TEXT;
""",
),
)


Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const distFiles = new Set(
"auth",
"bulk-scan-discovery",
"cli",
"codex-rollouts",
"config",
"contract",
"cost",
Expand All @@ -153,6 +154,7 @@ const distFiles = new Set(
"models",
"multiscan",
"result",
"rollout-session-index",
"runtime",
"scan-comparison",
"scan-history-renderer",
Expand Down
Loading