Skip to content

Commit 8d9eb30

Browse files
authored
fix: preserve authoritative security scan targets (#103)
1 parent f89633a commit 8d9eb30

12 files changed

Lines changed: 686 additions & 112 deletions

File tree

sdk/typescript/_bundled_plugin/references/scan-contract.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ A sealed manifest records the completed timestamp and hashes for the canonical d
3131
Choose the target kind based on the reviewed content, not the scan invocation:
3232
`git_worktree` for a checked-out Git workspace, `directory_snapshot` for a non-Git directory, `git_diff` for a Git-backed change set, and `git_revision` for an exact immutable Git tree.
3333

34+
For a workbench-backed scan, use the recorded target contract instead of inferring the kind from the checkout. A clean Git checkout has `allowedKinds: ["git_revision"]`: use its recorded revision and omit `snapshotDigest`. A dirty checkout has `allowedKinds: ["git_worktree"]`: copy `requiredSnapshotDigest` exactly.
35+
3436
| Kind | Required snapshot fields |
3537
| --- | --- |
3638
| `git_revision` | `revision` |

sdk/typescript/_bundled_plugin/scripts/workbench_cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ def parse_args(description: str) -> argparse.Namespace:
210210
update_progress.add_argument("--deep-review-pass", type=positive_int)
211211
update_progress.add_argument("--claim-token")
212212

213+
prepare_scan_completion = subparsers.add_parser("prepare-scan-completion")
214+
prepare_scan_completion.add_argument("--scan-id", required=True)
215+
prepare_scan_completion.add_argument("--claim-token")
216+
213217
complete_scan = subparsers.add_parser("complete-scan")
214218
complete_scan.add_argument("--scan-id", required=True)
215219
complete_scan.add_argument("--claim-token")

sdk/typescript/_bundled_plugin/scripts/workbench_db.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
safe_segment,
8787
scan_diff_identity,
8888
scan_target_identity,
89+
stored_diff_target,
8990
)
9091
from workbench_schema import MIGRATIONS, normalize_pre_release_migrations, sql_statements
9192
from workbench_source_excerpt import finding_source_excerpt
@@ -504,19 +505,6 @@ def expected_target_kinds(scan: sqlite3.Row) -> list[str]:
504505
return ["git_worktree"]
505506

506507

507-
def stored_diff_target(row: sqlite3.Row) -> dict[str, str] | None:
508-
if not row["diff_target_kind"]:
509-
return None
510-
target = {
511-
"baseRevision": row["diff_base_revision"],
512-
"headRevision": row["diff_head_revision"],
513-
"kind": row["diff_target_kind"],
514-
}
515-
if row["diff_content_digest"]:
516-
target["contentDigest"] = row["diff_content_digest"]
517-
return target
518-
519-
520508
def requested_scan_paths(scan: sqlite3.Row) -> list[str]:
521509
if "recipe_json" in scan.keys() and scan["recipe_json"] is not None:
522510
recipe = json.loads(scan["recipe_json"], parse_constant=reject_non_finite_json)
@@ -1374,18 +1362,24 @@ def pin_legacy_manifest_digest(
13741362
raise
13751363

13761364

1377-
def complete_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
1365+
def complete_scan(
1366+
connection: sqlite3.Connection, args: argparse.Namespace, *, prepare_only: bool = False
1367+
) -> dict[str, Any]:
13781368
scan_id = require_uuid(args.scan_id, "scan-id")
1379-
cost_json = parse_scan_cost(args.cost_json)
1369+
cost_json = None if prepare_only else parse_scan_cost(args.cost_json)
13801370
with scan_completion_lock(scan_id):
1381-
return complete_scan_locked(connection, scan_id, args.claim_token, cost_json)
1371+
return complete_scan_locked(
1372+
connection, scan_id, args.claim_token, cost_json, prepare_only=prepare_only
1373+
)
13821374

13831375

13841376
def complete_scan_locked(
13851377
connection: sqlite3.Connection,
13861378
scan_id: str,
13871379
claim_token: str | None,
13881380
cost_json: str | None,
1381+
*,
1382+
prepare_only: bool = False,
13891383
) -> dict[str, Any]:
13901384
scan = require_scan(connection, scan_id)
13911385
if scan["status"] == "complete":
@@ -1412,9 +1406,9 @@ def complete_scan_locked(
14121406
)
14131407
if scan["recipe_json"] is None:
14141408
deep_scan.require_deep_scan_ready_for_parent_completion(connection, scan)
1415-
warnings = []
1409+
warnings = json.loads(scan["completion_warnings_json"])
14161410
warning = scan_target_warning(scan)
1417-
if warning is not None:
1411+
if warning is not None and warning not in warnings:
14181412
warnings.append(warning)
14191413
scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"]))
14201414
completion_timestamp = now()
@@ -1443,9 +1437,23 @@ def complete_scan_locked(
14431437
for kind, filename in ARTIFACTS.items()
14441438
}
14451439
manifest_digest = published_manifest_digest(scan_dir, manifest)
1440+
if prepare_only:
1441+
connection.execute("BEGIN IMMEDIATE")
1442+
try:
1443+
updated = connection.execute(
1444+
"UPDATE scans SET completion_warnings_json = ? WHERE id = ? AND status = 'running'",
1445+
(json.dumps(warnings), scan["id"]),
1446+
)
1447+
if updated.rowcount != 1:
1448+
raise SystemExit("Only a running scan can be prepared for completion.")
1449+
connection.commit()
1450+
except BaseException:
1451+
connection.rollback()
1452+
raise
1453+
return scan_context(connection, scan["id"])
14461454
connection.execute("BEGIN IMMEDIATE")
14471455
try:
1448-
timestamp = completion_timestamp
1456+
timestamp = manifest["scan"]["completedAt"]
14491457
scan = require_scan(connection, scan["id"])
14501458
if scan["status"] == "complete":
14511459
connection.commit()
@@ -1609,7 +1617,14 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace)
16091617
except BaseException:
16101618
connection.rollback()
16111619
raise
1612-
return {"scanDir": str(scan_dir), "scanId": scan_id, "targetId": target_id}
1620+
scan = require_scan(connection, scan_id)
1621+
return {
1622+
"contract": scan_contract(scan),
1623+
"scanDir": str(scan_dir),
1624+
"scanId": scan_id,
1625+
"targetId": target_id,
1626+
"targetRevision": scan["target_revision"],
1627+
}
16131628

16141629

16151630
def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]:
@@ -3596,8 +3611,10 @@ def main() -> None:
35963611
require_scan=require_scan,
35973612
scan_context=scan_context,
35983613
)
3599-
elif args.command == "complete-scan":
3600-
result = complete_scan(connection, args)
3614+
elif args.command in {"prepare-scan-completion", "complete-scan"}:
3615+
result = complete_scan(
3616+
connection, args, prepare_only=args.command == "prepare-scan-completion"
3617+
)
36013618
elif args.command == "cancel-scan":
36023619
result = cancel_scan(connection, args)
36033620
elif args.command == "fail-scan":

sdk/typescript/_bundled_plugin/scripts/workbench_scan_start.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ def scan_diff_identity(
7272
)
7373

7474

75+
def stored_diff_target(row: sqlite3.Row) -> dict[str, str] | None:
76+
if not row["diff_target_kind"]:
77+
return None
78+
target = {
79+
"baseRevision": row["diff_base_revision"],
80+
"headRevision": row["diff_head_revision"],
81+
"kind": row["diff_target_kind"],
82+
}
83+
if row["diff_content_digest"]:
84+
target["contentDigest"] = row["diff_content_digest"]
85+
return target
86+
87+
7588
def archive_scan(
7689
connection: sqlite3.Connection,
7790
args: argparse.Namespace,

sdk/typescript/src/api.ts

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ export class CodexSecurity {
303303
let targetPathsFile: string | null = null;
304304
let knowledgeBase: PreparedKnowledgeBase | null = null;
305305
let costTracker: ScanCostTracker | null = null;
306+
let completionCost: ScanCost | null = null;
306307
let activeScan: {
307308
id: string;
308309
options: WorkbenchCommandOptions;
@@ -567,15 +568,50 @@ export class CodexSecurity {
567568
]);
568569
const scanId = registration["scanId"];
569570
const targetId = registration["targetId"];
571+
const contract = registration["contract"];
572+
const contractTarget = isRecord(contract)
573+
? contract["target"]
574+
: undefined;
575+
const allowedKinds = isRecord(contractTarget)
576+
? contractTarget["allowedKinds"]
577+
: undefined;
578+
const targetKind =
579+
Array.isArray(allowedKinds) && allowedKinds.length === 1
580+
? allowedKinds[0]
581+
: undefined;
582+
const diffTarget = isRecord(contract)
583+
? contract["diffTarget"]
584+
: undefined;
585+
const snapshotDigest =
586+
targetKind === "git_diff" && isRecord(diffTarget)
587+
? diffTarget["contentDigest"]
588+
: isRecord(contractTarget)
589+
? contractTarget["requiredSnapshotDigest"]
590+
: undefined;
591+
const registeredRevision = registration["targetRevision"];
570592
if (
571593
typeof scanId !== "string" ||
572594
typeof targetId !== "string" ||
573-
registration["scanDir"] !== scanDir
595+
registration["scanDir"] !== scanDir ||
596+
typeof targetKind !== "string" ||
597+
![
598+
"git_revision",
599+
"git_worktree",
600+
"git_diff",
601+
"directory_snapshot",
602+
].includes(targetKind) ||
603+
(snapshotDigest !== undefined && typeof snapshotDigest !== "string") ||
604+
((targetKind === "git_worktree" ||
605+
targetKind === "directory_snapshot") &&
606+
typeof snapshotDigest !== "string") ||
607+
typeof registeredRevision !== "string"
574608
) {
575609
throw new CodexSecurityError(
576610
"The Codex Security workbench returned an invalid scan registration.",
577611
);
578612
}
613+
const targetRevision =
614+
registeredRevision === "unversioned" ? null : registeredRevision;
579615
activeScan = { id: scanId, options: workbenchOptions };
580616
checkOpen();
581617
const feedback = await workbench(
@@ -642,6 +678,13 @@ export class CodexSecurity {
642678
CODEX_SECURITY_SCAN_ID: scanId,
643679
CODEX_SECURITY_TARGET_ID: targetId,
644680
CODEX_SECURITY_TARGET_DISPLAY_NAME: basename(repo),
681+
CODEX_SECURITY_TARGET_KIND: targetKind,
682+
...(targetRevision === null
683+
? {}
684+
: { CODEX_SECURITY_TARGET_REVISION: targetRevision }),
685+
...(typeof snapshotDigest === "string"
686+
? { CODEX_SECURITY_TARGET_SNAPSHOT_DIGEST: snapshotDigest }
687+
: {}),
645688
...(knowledgeBase === null
646689
? {}
647690
: { CODEX_SECURITY_KNOWLEDGE_BASE: knowledgeBase.path }),
@@ -703,6 +746,7 @@ export class CodexSecurity {
703746
scanDir,
704747
pluginRoot: runtime.plugin.installedRoot,
705748
expectation,
749+
workbenchValidated: true,
706750
model,
707751
onThreadStarted: (threadId) => tracker.start(threadId),
708752
onFinalize: async (usage) => {
@@ -716,30 +760,12 @@ export class CodexSecurity {
716760
"Scan completed, but its cost limit could not be verified because model pricing or token usage is unavailable.",
717761
);
718762
}
719-
const cost = snapshot.cost;
720-
const completion = await workbench(workbenchOptions, [
721-
"complete-scan",
763+
completionCost = snapshot.cost;
764+
await workbench(workbenchOptions, [
765+
"prepare-scan-completion",
722766
"--scan-id",
723767
scanId,
724-
...(cost === null ? [] : ["--cost-json", JSON.stringify(cost)]),
725768
]);
726-
activeScan = null;
727-
const completedScan = completion["scan"];
728-
if (
729-
isRecord(completedScan) &&
730-
Array.isArray(completedScan["warnings"])
731-
) {
732-
for (const warning of completedScan["warnings"]) {
733-
if (typeof warning === "string") {
734-
notifyObserver(
735-
"onWarning",
736-
options.onWarning,
737-
options.onObserverError,
738-
warning,
739-
);
740-
}
741-
}
742-
}
743769
return snapshot.usage;
744770
},
745771
onScanStarted: options.onScanStarted,
@@ -748,6 +774,28 @@ export class CodexSecurity {
748774
onObserverError: options.onObserverError,
749775
});
750776
checkOpen();
777+
const completion = await workbench(workbenchOptions, [
778+
"complete-scan",
779+
"--scan-id",
780+
scanId,
781+
...(completionCost === null
782+
? []
783+
: ["--cost-json", JSON.stringify(completionCost)]),
784+
]);
785+
activeScan = null;
786+
const completedScan = completion["scan"];
787+
if (isRecord(completedScan) && Array.isArray(completedScan["warnings"])) {
788+
for (const warning of completedScan["warnings"]) {
789+
if (typeof warning === "string") {
790+
notifyObserver(
791+
"onWarning",
792+
options.onWarning,
793+
options.onObserverError,
794+
warning,
795+
);
796+
}
797+
}
798+
}
751799
return result;
752800
} catch (error) {
753801
const snapshot = await costTracker?.stop().catch(() => null);
@@ -1154,6 +1202,7 @@ interface ScanEventRunOptions {
11541202
scanDir: string;
11551203
pluginRoot: string;
11561204
expectation: ScanExpectation;
1205+
workbenchValidated?: boolean;
11571206
model?: string;
11581207
onFinalize?: (usage: unknown) => Promise<unknown>;
11591208
onThreadStarted?: (threadId: string) => void;
@@ -1273,6 +1322,7 @@ export async function runScanEvents(
12731322
options.pluginRoot,
12741323
options.expectation,
12751324
options.signal,
1325+
options.workbenchValidated,
12761326
);
12771327
if (options.signal.aborted) {
12781328
throw new ScanInterruptedError(
@@ -1326,6 +1376,9 @@ async function scanPrompt(
13261376
'Use exactly "$CODEX_SECURITY_SCAN_ID" as the scan ID in the manifest, findings, and coverage.',
13271377
'Use exactly "$CODEX_SECURITY_TARGET_ID" as scan.target.targetId; do not derive a different target ID.',
13281378
'Use exactly "$CODEX_SECURITY_TARGET_DISPLAY_NAME" as scan.target.displayName; do not infer a display name from the Git remote.',
1379+
'Use exactly "$CODEX_SECURITY_TARGET_KIND" as scan.target.kind; do not infer the target kind from the checkout.',
1380+
'When "$CODEX_SECURITY_TARGET_REVISION" is set, use its exact value as scan.target.revision.',
1381+
'When "$CODEX_SECURITY_TARGET_SNAPSHOT_DIGEST" is set, use its exact value as scan.target.snapshotDigest. For git_revision, omit scan.target.snapshotDigest.',
13291382
'Use exactly "codex-security-plugin" as scan.producer.name.',
13301383
...(hasConfigPath
13311384
? [
@@ -1416,6 +1469,7 @@ async function collectResult(
14161469
pluginRoot: string,
14171470
expectation: ScanExpectation,
14181471
signal: AbortSignal,
1472+
workbenchValidated = false,
14191473
): Promise<ScanResult> {
14201474
const required = [
14211475
"scan-manifest.json",
@@ -1440,6 +1494,7 @@ async function collectResult(
14401494
const { manifest, findings, coverage } = await loadContract(scanDir, {
14411495
pluginRoot,
14421496
expectation,
1497+
workbenchValidated,
14431498
signal,
14441499
});
14451500
let sarifPath: string | null = null;

0 commit comments

Comments
 (0)