Skip to content

Commit 4b565fe

Browse files
committed
Merge remote-tracking branch 'origin/main' into HEAD
2 parents 91afffe + 808e9bb commit 4b565fe

26 files changed

Lines changed: 3650 additions & 1079 deletions

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ value, including when no stored sign-in exists.
5858
Scan a subset of a repository or write machine-readable results:
5959

6060
```bash
61+
npx codex-security scan /path/to/repo --model gpt-5.6-terra
6162
npx codex-security scan /path/to/repo --path src --path tests
6263
npx codex-security scan /path/to/repo --knowledge-base /path/to/threat-models --knowledge-base /path/to/architecture.pdf
6364
npx codex-security scan /path/to/repo --diff origin/main --json
@@ -148,11 +149,11 @@ JSON scans remain noninteractive, including when stderr is a terminal. Commands
148149
that run Codex interactively (`validate`, `patch`, `login`, and `logout`) reject
149150
`--json`. Write CSV exports to a file when JSON output is selected.
150151

151-
Scans use `gpt-5.6-sol` with extra-high reasoning effort by default. To override
152-
either setting, pass valid TOML values (including quotes for strings):
152+
Scans use `gpt-5.6-sol` with extra-high reasoning effort by default. Switch
153+
models with `--model`. Use `--codex` for other Codex settings:
153154

154155
```bash
155-
npx codex-security scan . --codex 'model="gpt-5.6-sol"' --codex 'model_reasoning_effort="high"'
156+
npx codex-security scan . --model gpt-5.6-terra --codex 'model_reasoning_effort="high"'
156157
```
157158

158159
Scans report their requested paths and actual ranking, file-review, validation,

sdk/typescript/README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@ stored sign-in exists.
9696

9797
```bash
9898
npx codex-security scan /path/to/repository
99+
npx codex-security scan /path/to/repository --model gpt-5.6-terra
99100
npx codex-security scan /path/to/repository --path src --path tests
100101
npx codex-security scan /path/to/repository --knowledge-base /path/to/threat-models --knowledge-base /path/to/architecture.pdf
101102
npx codex-security scan /path/to/repository --diff origin/main --json
102103
npx codex-security scan /path/to/repository --output-dir /path/outside/repository/results
103104
npx codex-security scan /path/to/repository --output-dir /path/outside/repository/results --archive-existing
104105
npx codex-security scan /path/to/repository --dry-run
105106
npx codex-security scan /path/to/repository --fail-on-severity high
107+
npx codex-security scan /path/to/repository --max-cost 5
106108
npx codex-security bulk-scan
107109
npx codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --workers 4
108110
npx codex-security scans list /path/to/repository
@@ -150,16 +152,27 @@ for a passing policy. Incomplete scans still write the available human or JSON
150152
result to stdout and a coverage warning to stderr, including in report-only
151153
mode.
152154

153-
Scans use `gpt-5.6-sol` with extra-high reasoning effort by default. Override
154-
either setting with repeatable `--codex KEY=VALUE` options, for example
155-
`--codex 'model="gpt-5.6-sol"' --codex 'model_reasoning_effort="high"'`.
155+
Scans use `gpt-5.6-sol` with extra-high reasoning effort by default. Use
156+
`--model gpt-5.6-terra` to switch models. Use repeatable `--codex KEY=VALUE`
157+
options for other Codex settings, such as
158+
`--codex 'model_reasoning_effort="high"'`.
156159

157160
Scan progress identifies the requested paths and reports actual ranking,
158161
file-review, validation, and attack-path phases as they become available.
159162
Completion summarizes findings, severity, coverage, elapsed time, available
160-
token and worker counts, the results directory, and the next useful command.
163+
token and worker counts, estimated cost, the results directory, and the next
164+
useful command.
161165
Progress and summaries use stderr; structured scan results remain on stdout.
162166

167+
Each scan records its model, tokens, and estimated cost in its JSON result,
168+
scan history, and bulk-scan receipt. Estimates use
169+
[standard API token prices](https://developers.openai.com/api/docs/models/compare),
170+
including cached input and cache writes; fees and surcharges are not included.
171+
172+
Use `--max-cost USD` to stop a scan, including its delegated workers, when its
173+
running cost exceeds the limit. Partial results are preserved. Requests
174+
already in progress can finish above the limit.
175+
163176
Run `npx codex-security scan --help` or `npx codex-security bulk-scan --help`
164177
for the complete CLI references.
165178

@@ -190,6 +203,9 @@ repository path to inspect another checkout, `--scan-root DIR` to list scans
190203
whose artifacts are under a particular root. `scans show SCAN_ID` includes the
191204
scan configuration, results, coverage, and artifact locations.
192205

206+
Every scan history command accepts a full scan ID or a unique prefix of at
207+
least eight characters.
208+
193209
Scan history uses the existing Codex Security workbench database at
194210
`$CODEX_HOME/state/plugins/codex-security/workbench.sqlite3`. Set
195211
`CODEX_SECURITY_STATE_DIR` to place the database elsewhere. Scan credentials

sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
import argparse
77
import json
8+
import os
9+
import stat
810
import sys
911
from pathlib import Path
1012

13+
MAX_SECURITY_MD_BYTES = 1024 * 1024
14+
1115

1216
class ResolutionError(ValueError):
1317
"""Raised when a SECURITY.md chain cannot be resolved."""
@@ -20,14 +24,50 @@ def _inside(path: Path, root: Path, label: str) -> Path:
2024
raise ResolutionError(f"{label} is outside the scan root: {path}") from exc
2125

2226

23-
def resolve_security_md(repo: Path, scope: Path) -> str:
24-
"""Return applicable SECURITY.md files, concatenated root to leaf."""
27+
def _resolve_root(repo: Path) -> Path:
2528
try:
2629
root = repo.expanduser().resolve(strict=True)
2730
except OSError as exc:
2831
raise ResolutionError(f"scan root does not exist: {repo}") from exc
2932
if not root.is_dir():
3033
raise ResolutionError(f"scan root is not a directory: {root}")
34+
return root
35+
36+
37+
def list_security_md(repo: Path) -> list[str]:
38+
"""Return a stable, safely framed inventory without traversing Git metadata."""
39+
root = _resolve_root(repo)
40+
41+
def raise_walk_error(error: OSError) -> None:
42+
raise error
43+
44+
policies: list[str] = []
45+
for directory, subdirectories, filenames in os.walk(
46+
root, onerror=raise_walk_error, followlinks=False
47+
):
48+
safe_subdirectories: list[str] = []
49+
for name in sorted(subdirectories):
50+
if name == ".git":
51+
continue
52+
directory_stat = (Path(directory) / name).stat(follow_symlinks=False)
53+
if not stat.S_ISDIR(directory_stat.st_mode):
54+
continue
55+
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
56+
if getattr(directory_stat, "st_file_attributes", 0) & reparse_point:
57+
continue
58+
safe_subdirectories.append(name)
59+
subdirectories[:] = safe_subdirectories
60+
if "SECURITY.md" not in filenames:
61+
continue
62+
policy = Path(directory) / "SECURITY.md"
63+
if policy.is_file() or policy.is_symlink():
64+
policies.append(policy.relative_to(root).as_posix())
65+
return sorted(policies)
66+
67+
68+
def resolve_security_md(repo: Path, scope: Path) -> str:
69+
"""Return applicable SECURITY.md files, concatenated root to leaf."""
70+
root = _resolve_root(repo)
3171

3272
requested_scope = scope.expanduser()
3373
if not requested_scope.is_absolute():
@@ -54,7 +94,11 @@ def resolve_security_md(repo: Path, scope: Path) -> str:
5494
resolved_policy = policy.resolve(strict=True)
5595
_inside(resolved_policy, root, "SECURITY.md")
5696
try:
57-
content = policy.read_bytes().decode("utf-8")
97+
with resolved_policy.open("rb") as policy_file:
98+
policy_bytes = policy_file.read(MAX_SECURITY_MD_BYTES + 1)
99+
if len(policy_bytes) > MAX_SECURITY_MD_BYTES:
100+
raise ResolutionError(f"SECURITY.md exceeds 1 MiB: {policy}")
101+
content = policy_bytes.decode("utf-8")
58102
except UnicodeDecodeError as exc:
59103
raise ResolutionError(f"SECURITY.md is not valid UTF-8: {policy}") from exc
60104
if not content.strip():
@@ -72,22 +116,35 @@ def resolve_security_md(repo: Path, scope: Path) -> str:
72116
def parse_args() -> argparse.Namespace:
73117
parser = argparse.ArgumentParser(description=__doc__)
74118
parser.add_argument("--repo", required=True, type=Path, help="scan root directory")
119+
parser.add_argument(
120+
"--list",
121+
action="store_true",
122+
help="write a JSON inventory of repository policy paths",
123+
)
75124
parser.add_argument(
76125
"--scope",
77-
required=True,
78126
type=Path,
79127
help="existing file or directory within the scan root",
80128
)
81-
parser.add_argument("--out", required=True, type=Path, help="output Markdown path, or -")
82-
return parser.parse_args()
129+
parser.add_argument("--out", default=Path("-"), type=Path, help="output path, or - for stdout")
130+
args = parser.parse_args()
131+
if args.list and args.scope is not None:
132+
parser.error("--list cannot be combined with --scope")
133+
if not args.list and args.scope is None:
134+
parser.error("--scope is required unless --list is specified")
135+
return args
83136

84137

85138
def main() -> int:
86139
args = parse_args()
87140
try:
88-
guidance = resolve_security_md(args.repo, args.scope)
141+
guidance = (
142+
json.dumps(list_security_md(args.repo), ensure_ascii=True) + "\n"
143+
if args.list
144+
else resolve_security_md(args.repo, args.scope)
145+
)
89146
if args.out == Path("-"):
90-
sys.stdout.write(guidance)
147+
sys.stdout.buffer.write(guidance.encode("utf-8"))
91148
else:
92149
args.out.parent.mkdir(parents=True, exist_ok=True)
93150
args.out.write_text(guidance, encoding="utf-8")

sdk/typescript/_bundled_plugin/scripts/workbench_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def parse_args(description: str) -> argparse.Namespace:
208208
complete_scan = subparsers.add_parser("complete-scan")
209209
complete_scan.add_argument("--scan-id", required=True)
210210
complete_scan.add_argument("--claim-token")
211+
complete_scan.add_argument("--cost-json")
211212

212213
cancel_scan = subparsers.add_parser("cancel-scan")
213214
cancel_scan.add_argument("--scan-id", required=True)
@@ -217,6 +218,7 @@ def parse_args(description: str) -> argparse.Namespace:
217218
fail_scan.add_argument("--scan-id", required=True)
218219
fail_scan.add_argument("--message", required=True)
219220
fail_scan.add_argument("--claim-token")
221+
fail_scan.add_argument("--cost-json")
220222

221223
mark_handoff_delivered = subparsers.add_parser("mark-handoff-delivered")
222224
mark_handoff_delivered.add_argument("--scan-id", required=True)

sdk/typescript/_bundled_plugin/scripts/workbench_db.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
capability_preflight_input,
107107
capability_preflight_json,
108108
optional_text,
109+
parse_scan_cost,
109110
require_occurrence,
110111
require_uuid,
111112
)
@@ -774,13 +775,32 @@ def require_workspace(connection: sqlite3.Connection, workspace_id: str) -> sqli
774775

775776

776777
def require_scan(connection: sqlite3.Connection, scan_id: str) -> sqlite3.Row:
777-
scan_id = require_uuid(scan_id, "scan-id")
778+
scan_id = resolve_scan_id(connection, scan_id)
778779
row = connection.execute("SELECT * FROM scans WHERE id = ?", (scan_id,)).fetchone()
779780
if row is None:
780781
raise SystemExit("Codex Security scan not found.")
781782
return row
782783

783784

785+
def resolve_scan_id(connection: sqlite3.Connection, scan_id: str) -> str:
786+
try:
787+
return str(uuid.UUID(scan_id))
788+
except ValueError:
789+
if len(scan_id) < 8:
790+
raise SystemExit("Scan ID prefixes must be at least eight characters.") from None
791+
matches = connection.execute(
792+
"SELECT id FROM scans WHERE substr(id, 1, ?) = ? LIMIT 2",
793+
(len(scan_id), scan_id.lower()),
794+
).fetchall()
795+
if not matches:
796+
raise SystemExit("Codex Security scan not found.") from None
797+
if len(matches) > 1:
798+
raise SystemExit(
799+
f'Scan ID prefix "{scan_id}" matches multiple scans; use a longer prefix.'
800+
) from None
801+
return matches[0]["id"]
802+
803+
784804
def create_workspace(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
785805
workspace_id = require_uuid(args.workspace_id, "workspace-id")
786806
timestamp = now()
@@ -1434,12 +1454,16 @@ def pin_legacy_manifest_digest(
14341454

14351455
def complete_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
14361456
scan_id = require_uuid(args.scan_id, "scan-id")
1457+
cost_json = parse_scan_cost(args.cost_json)
14371458
with scan_completion_lock(scan_id):
1438-
return complete_scan_locked(connection, scan_id, args.claim_token)
1459+
return complete_scan_locked(connection, scan_id, args.claim_token, cost_json)
14391460

14401461

14411462
def complete_scan_locked(
1442-
connection: sqlite3.Connection, scan_id: str, claim_token: str | None
1463+
connection: sqlite3.Connection,
1464+
scan_id: str,
1465+
claim_token: str | None,
1466+
cost_json: str | None,
14431467
) -> dict[str, Any]:
14441468
scan = require_scan(connection, scan_id)
14451469
if scan["status"] == "complete":
@@ -1532,10 +1556,10 @@ def complete_scan_locked(
15321556
"""
15331557
UPDATE scans
15341558
SET status = 'complete', phase = 'reporting', completed_at = ?, updated_at = ?,
1535-
seal_manifest_digest = ?
1559+
seal_manifest_digest = ?, cost_json = ?
15361560
WHERE id = ? AND status = 'running'
15371561
""",
1538-
(timestamp, timestamp, manifest_digest, scan["id"]),
1562+
(timestamp, timestamp, manifest_digest, cost_json, scan["id"]),
15391563
)
15401564
if updated.rowcount != 1:
15411565
raise SystemExit("Only a running scan can be completed.")
@@ -1731,6 +1755,7 @@ def coverage_for_comparison(scan: sqlite3.Row) -> dict[str, Any]:
17311755

17321756
def fail_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
17331757
scan_id = require_uuid(args.scan_id, "scan-id")
1758+
cost_json = parse_scan_cost(args.cost_json)
17341759
connection.execute("BEGIN IMMEDIATE")
17351760
try:
17361761
timestamp = now()
@@ -1748,10 +1773,17 @@ def fail_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[
17481773
updated = connection.execute(
17491774
"""
17501775
UPDATE scans
1751-
SET status = 'failed', failure_message = ?, completed_at = ?, updated_at = ?
1776+
SET status = 'failed', failure_message = ?, completed_at = ?, updated_at = ?,
1777+
cost_json = ?
17521778
WHERE id = ? AND status = 'running'
17531779
""",
1754-
(optional_text(args.message, maximum=2400), timestamp, timestamp, scan["id"]),
1780+
(
1781+
optional_text(args.message, maximum=2400),
1782+
timestamp,
1783+
timestamp,
1784+
cost_json,
1785+
scan["id"],
1786+
),
17551787
)
17561788
if updated.rowcount != 1:
17571789
raise SystemExit("Only a running scan can be marked failed.")
@@ -3006,6 +3038,11 @@ def scan_result(
30063038
return {
30073039
"artifacts": artifacts,
30083040
"canceledAt": scan["canceled_at"],
3041+
**(
3042+
{"cost": json.loads(scan["cost_json"], parse_constant=reject_non_finite_json)}
3043+
if scan["cost_json"] is not None
3044+
else {}
3045+
),
30093046
"contract": scan_contract(scan),
30103047
"continuationThreadId": scan["continuation_thread_id"],
30113048
"failureMessage": scan["failure_message"],
@@ -3208,9 +3245,13 @@ def finding_result(
32083245
"title": bounded_output_text(occurrence["title"], FINDING_TITLE_BYTES),
32093246
"triage": finding_triage_result(connection, occurrence["id"]),
32103247
}
3211-
matches = scan_history.finding_matches(connection, occurrence["id"])
3248+
matches, known_since, known_scan_ids = scan_history.finding_matches(
3249+
connection, occurrence["id"], scan["id"], scan["started_at"]
3250+
)
32123251
if matches:
32133252
result["matches"] = matches
3253+
result["knownSince"] = known_since
3254+
result["knownScanIds"] = known_scan_ids
32143255
result.pop("artifactPaths", None)
32153256
source_excerpt = finding_source_excerpt(scan, target, locations)
32163257
if source_excerpt:

0 commit comments

Comments
 (0)