diff --git a/sdk/typescript/_bundled_plugin/mcp/mcp-app.html.br b/sdk/typescript/_bundled_plugin/mcp/mcp-app.html.br index cef73321..1e6c2518 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/mcp-app.html.br and b/sdk/typescript/_bundled_plugin/mcp/mcp-app.html.br differ diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 index 74662af1..571dcb13 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 differ diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 index 74aedd5f..4a38b922 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 differ diff --git a/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py b/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py index 6f52950d..2a573d01 100644 --- a/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py +++ b/sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py @@ -40,6 +40,8 @@ def register_subcommands(subparsers: Any, positive_int: Callable[[str], int]) -> begin_deep_scan.add_argument("--user-context") begin_deep_scan.add_argument("--scan-root") begin_deep_scan.add_argument("--claim-token") + begin_deep_scan.add_argument("--model") + begin_deep_scan.add_argument("--reasoning-effort") begin_deep_scan.add_argument("--available-parallelism", type=positive_int) begin_deep_scan.add_argument("--workflow-version", default=DEEP_SCAN_WORKFLOW_VERSION) @@ -324,7 +326,7 @@ def independent_review_progress( connection: sqlite3.Connection, scan_id: str ) -> dict[str, int | str] | None: run = connection.execute( - "SELECT completion_sequence, updated_at FROM deep_scan_runs WHERE scan_id = ?", + "SELECT completion_sequence, phase, updated_at FROM deep_scan_runs WHERE scan_id = ?", (scan_id,), ).fetchone() if run is None: @@ -342,6 +344,7 @@ def independent_review_progress( return { "active": int(active), "completed": int(run["completion_sequence"]), + "consolidating": run["phase"] == "reducing", "updatedAt": str(run["updated_at"]), } @@ -569,6 +572,18 @@ def begin_deep_scan_for_scan( ) if scan["mode"] != "deep": raise SystemExit("Deep Scan orchestration requires a scan in deep mode.") + model = optional_text(args.model, maximum=200) + reasoning_effort = optional_text(args.reasoning_effort, maximum=32) + if model is not None or reasoning_effort is not None: + connection.execute( + """ + UPDATE scans + SET model = COALESCE(?, model), reasoning_effort = COALESCE(?, reasoning_effort) + WHERE id = ? + """, + (model, reasoning_effort, scan_id), + ) + connection.commit() existing = connection.execute( "SELECT scan_id FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) ).fetchone() @@ -681,6 +696,8 @@ def begin_deep_scan_for_target( raise SystemExit("The scan artifact directory must be outside the selected target.") target_root.mkdir(parents=True, exist_ok=True) user_context = optional_text(args.user_context) + model = optional_text(args.model, maximum=200) + reasoning_effort = optional_text(args.reasoning_effort, maximum=32) workspace_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) timestamp = now() @@ -715,10 +732,10 @@ def begin_deep_scan_for_target( INSERT INTO scans ( id, workspace_id, target_id, target_path, target_revision, target_snapshot_digest, target_device, target_inode, scope, mode, user_context, - deep_scan_owner_thread_id, scan_dir, status, phase, handoff_status, - started_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'deep', ?, ?, ?, 'running', 'preflight', - 'delivered', ?, ?, ?) + deep_scan_owner_thread_id, scan_dir, model, reasoning_effort, status, phase, + handoff_status, started_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'deep', ?, ?, ?, ?, ?, + 'running', 'preflight', 'delivered', ?, ?, ?) """, ( scan_id, @@ -733,6 +750,8 @@ def begin_deep_scan_for_target( user_context, thread_id, str(scan_dir), + model, + reasoning_effort, timestamp, timestamp, timestamp, @@ -1114,6 +1133,14 @@ def claim_deep_scan_dedup( "UPDATE deep_scan_runs SET phase = 'reducing', updated_at = ? WHERE scan_id = ?", (timestamp, scan_id), ) + connection.execute( + """ + UPDATE scan_progress + SET deep_review_pass = COALESCE(deep_review_pass, 0) + 1, updated_at = ? + WHERE scan_id = ? + """, + (timestamp, scan_id), + ) connection.commit() except BaseException: connection.rollback() diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index 8e0c38e9..7ffbeb13 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -113,6 +113,8 @@ def parse_args(description: str) -> argparse.Namespace: start_scan = subparsers.add_parser("start-scan") start_scan.add_argument("--workspace-id", required=True) start_scan.add_argument("--scan-root") + start_scan.add_argument("--model") + start_scan.add_argument("--reasoning-effort") disable_setup_ui = subparsers.add_parser("disable-setup-ui") disable_setup_ui.add_argument("--workspace-id", required=True) @@ -129,6 +131,8 @@ def parse_args(description: str) -> argparse.Namespace: start_prompt_only_scan.add_argument("--diff-head-revision") start_prompt_only_scan.add_argument("--diff-content-digest") start_prompt_only_scan.add_argument("--scan-root") + start_prompt_only_scan.add_argument("--model") + start_prompt_only_scan.add_argument("--reasoning-effort") deep_scan.register_subcommands(subparsers, positive_int) @@ -209,6 +213,8 @@ def parse_args(description: str) -> argparse.Namespace: update_progress.add_argument("--reportable-findings-count", type=non_negative_int) update_progress.add_argument("--deep-review-pass", type=positive_int) update_progress.add_argument("--claim-token") + update_progress.add_argument("--model") + update_progress.add_argument("--reasoning-effort") prepare_scan_completion = subparsers.add_parser("prepare-scan-completion") prepare_scan_completion.add_argument("--scan-id", required=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 70c6a3cd..004be062 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -79,6 +79,7 @@ SQLITE_RETRY_ATTEMPTS, ) from workbench_feedback import get_scan_feedback +from workbench_remediation import remediation_claim_is_active from workbench_scan_start import ( archive_scan, compact_timestamp, @@ -137,23 +138,6 @@ def stale_claim_before(seconds: int = CLAIM_LEASE_SECONDS) -> str: ) -def remediation_claim_is_active(remediation: sqlite3.Row) -> bool: - if remediation["pending_action_claim_token"] is None: - return False - delivered_at = remediation["pending_action_delivered_at"] - claimed_at = delivered_at or remediation["pending_action_claimed_at"] - if not isinstance(claimed_at, str): - return True - try: - parsed = datetime.fromisoformat(claimed_at) - if parsed.tzinfo is None: - return True - except ValueError: - return True - lease_seconds = DELIVERED_ACTION_LEASE_SECONDS if delivered_at else CLAIM_LEASE_SECONDS - return parsed > datetime.now(timezone.utc) - timedelta(seconds=lease_seconds) - - def state_dir() -> Path: state_dir = os.environ.get("CODEX_SECURITY_STATE_DIR") if state_dir: @@ -1172,6 +1156,8 @@ def start_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict target_summary=target_summary, scope_file_count=scope_file_count, timestamp=timestamp, + model=args.model, + reasoning_effort=args.reasoning_effort, ) if manages_transaction: connection.commit() @@ -1309,6 +1295,8 @@ def start_prompt_only_scan( scope_file_count=scope_file_count, timestamp=timestamp, handoff_status="delivered", + model=args.model, + reasoning_effort=args.reasoning_effort, ) connection.commit() except BaseException: @@ -3004,6 +2992,7 @@ def scan_result( progress_result["independentReviews"] = { "active": independent_reviews["active"], "completed": independent_reviews["completed"], + "consolidating": independent_reviews["consolidating"], } return { "artifacts": artifacts, @@ -3024,8 +3013,10 @@ def scan_result( "handoffClaimToken": scan["handoff_claim_token"], "handoffStatus": scan["handoff_status"], "mode": scan["mode"], + "model": scan["model"], "diffTarget": stored_diff_target(scan), "progress": progress_result, + "reasoningEffort": scan["reasoning_effort"], "remediationAvailable": remediation_available, "remediationUnavailableReason": remediation_unavailable_reason, "reportAvailable": "markdownReport" in artifacts, @@ -3647,13 +3638,9 @@ def main() -> None: read_coverage=coverage_for_comparison, ) elif args.command == "list-global-findings": - result = native_indexes.list_global_findings( - connection, args, read_coverage=coverage_for_comparison - ) + result = native_indexes.list_global_findings(connection, args) elif args.command == "list-repositories": - result = native_indexes.list_repositories( - connection, args, read_coverage=coverage_for_comparison - ) + result = native_indexes.list_repositories(connection, args) elif args.command == "list-findings": result = list_findings(connection, args) elif args.command == "update-progress": diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py b/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py index af424a67..80e825fe 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py @@ -4,7 +4,7 @@ import sqlite3 import sys from collections import Counter -from collections.abc import Callable, Iterator +from collections.abc import Iterator from itertools import islice from pathlib import Path from typing import Any @@ -19,14 +19,12 @@ def list_global_findings( connection: sqlite3.Connection, args: argparse.Namespace, - *, - read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: limit = min(args.limit, FINDINGS_PAGE_MAX) query = args.query.strip().casefold() if args.query else "" findings = ( row - for row in _active_findings(connection, read_coverage) + for row in _indexed_findings(connection) if (args.target_id is None or row["target_id"] == args.target_id) and (args.severity is None or row["severity"] == args.severity) and (args.status is None or row["status"] == args.status) @@ -72,23 +70,8 @@ def list_global_findings( } -def _active_findings( - connection: sqlite3.Connection, - read_coverage: Callable[[sqlite3.Row], dict[str, Any]], -) -> Iterator[sqlite3.Row]: - completed_scans_by_target: dict[str, list[sqlite3.Row]] = {} - for scan in connection.execute( - """ - SELECT * - FROM scans - WHERE status = 'complete' AND seal_manifest_digest IS NOT NULL - ORDER BY started_at DESC, id DESC - """ - ): - completed_scans_by_target.setdefault(scan["target_id"], []).append(scan) - - coverage_by_scan_id: dict[str, dict[str, Any]] = {} - rows = connection.execute( +def _indexed_findings(connection: sqlite3.Connection) -> Iterator[sqlite3.Row]: + yield from connection.execute( """ WITH ranked_findings AS ( SELECT @@ -97,7 +80,6 @@ def _active_findings( occurrences.severity, occurrences.created_at, scans.id AS scan_id, - scans.started_at AS scan_started_at, scans.target_id, targets.current_path AS target_path, scans.scope, @@ -146,35 +128,11 @@ def _active_findings( selected_findings.occurrence_id """, ) - for row in rows: - resolved = False - for scan in completed_scans_by_target.get(row["target_id"], ()): - if (scan["started_at"], scan["id"]) <= ( - row["scan_started_at"], - row["scan_id"], - ): - break - coverage = coverage_by_scan_id.get(scan["id"]) - if coverage is None: - coverage = read_coverage(scan) - coverage_by_scan_id[scan["id"]] = coverage - if scan_history.scan_covers_path( - scan, - target_id=row["target_id"], - path=row["location_path"], - coverage=coverage, - ): - resolved = True - break - if not resolved: - yield row def list_repositories( connection: sqlite3.Connection, args: argparse.Namespace | None = None, - *, - read_coverage: Callable[[sqlite3.Row], dict[str, Any]], ) -> dict[str, Any]: scans = scan_history.list_scans(connection)["scans"] scans_by_id = {scan["scanId"]: scan for scan in scans} @@ -190,9 +148,7 @@ def list_repositories( latest_scan_by_target.setdefault(row["target_id"], scans_by_id[row["id"]]) open_findings_by_target = Counter( - row["target_id"] - for row in _active_findings(connection, read_coverage) - if row["status"] == "open" + row["target_id"] for row in _indexed_findings(connection) if row["status"] == "open" ) targets = {row["id"]: row for row in connection.execute("SELECT * FROM security_targets")} repositories = [ diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_progress.py b/sdk/typescript/_bundled_plugin/scripts/workbench_progress.py index 5bf13d8c..cd6c29f9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_progress.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_progress.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from workbench.handoff import require_current_continuation from workbench_constants import PHASES -from workbench_validation import require_uuid +from workbench_validation import optional_text, require_uuid MAX_PREFLIGHT_ISSUES_JSON_BYTES = 64 * 1024 MAX_PREFLIGHT_ISSUES = 32 @@ -86,6 +86,8 @@ def update_progress( scan_context: Callable[[sqlite3.Connection, str], dict[str, Any]], ) -> dict[str, Any]: scan_id = require_uuid(args.scan_id, "scan-id") + model = optional_text(args.model, maximum=200) + reasoning_effort = optional_text(args.reasoning_effort, maximum=32) serialized_preflight_issues = preflight_issues_json(args.preflight_issues_json) connection.execute("BEGIN IMMEDIATE") try: @@ -189,10 +191,11 @@ def update_progress( updated = connection.execute( """ UPDATE scans - SET phase = COALESCE(?, phase), updated_at = ? + SET phase = COALESCE(?, phase), model = COALESCE(?, model), + reasoning_effort = COALESCE(?, reasoning_effort), updated_at = ? WHERE id = ? AND status = 'running' """, - (args.phase, timestamp, scan["id"]), + (args.phase, model, reasoning_effort, timestamp, scan["id"]), ) if updated.rowcount != 1: raise SystemExit("Only a running scan can update progress.") diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_remediation.py b/sdk/typescript/_bundled_plugin/scripts/workbench_remediation.py index ffb661aa..afb1c6c9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_remediation.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_remediation.py @@ -5,15 +5,33 @@ import argparse import sqlite3 import sys -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workbench_constants import CLAIM_LEASE_SECONDS, DELIVERED_ACTION_LEASE_SECONDS from workbench_validation import require_occurrence, require_uuid +def remediation_claim_is_active(remediation: sqlite3.Row) -> bool: + if remediation["pending_action_claim_token"] is None: + return False + delivered_at = remediation["pending_action_delivered_at"] + claimed_at = delivered_at or remediation["pending_action_claimed_at"] + if not isinstance(claimed_at, str): + return True + try: + parsed = datetime.fromisoformat(claimed_at) + if parsed.tzinfo is None: + return True + except ValueError: + return True + lease_seconds = DELIVERED_ACTION_LEASE_SECONDS if delivered_at else CLAIM_LEASE_SECONDS + return parsed > datetime.now(timezone.utc) - timedelta(seconds=lease_seconds) + + def register_cancel_finding_remediation_request(subparsers: Any) -> None: parser = subparsers.add_parser("cancel-finding-remediation-request") parser.add_argument("--occurrence-id", required=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py index c92f1490..514e9d76 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_history.py @@ -225,6 +225,7 @@ def list_scans( "findingCount": row["finding_count"], "handoffStatus": row["handoff_status"], "mode": row["mode"], + "model": row["model"], "parentScanId": row["parent_scan_id"], "progress": { "candidates": {"reportable": row["reportable_findings_count"]}, @@ -238,6 +239,7 @@ def list_scans( "updatedAt": row["progress_updated_at"], }, "recipeAvailable": row["recipe_json"] is not None, + "reasoningEffort": row["reasoning_effort"], "scanDir": row["scan_dir"], "scanId": row["id"], "scope": row["scope"], diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_start.py b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_start.py index 103b1e65..4d5819c6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_scan_start.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_scan_start.py @@ -22,6 +22,7 @@ git_revision, worktree_content_digest, ) +from workbench_validation import optional_text def safe_segment(value: str) -> str: @@ -161,6 +162,8 @@ def insert_running_scan( scope_file_count: int, timestamp: str, handoff_status: str = "pending", + model: str | None = None, + reasoning_effort: str | None = None, scan_dir: Path | None = None, ) -> str: revision = target_identity[0] @@ -178,10 +181,10 @@ def insert_running_scan( id, workspace_id, target_id, target_path, target_revision, target_snapshot_digest, target_device, target_inode, scope, mode, user_context, deep_scan_owner_thread_id, diff_target_kind, diff_base_revision, - diff_head_revision, diff_content_digest, target_summary, scan_dir, status, phase, - handoff_status, started_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'preflight', - ?, ?, ?, ?) + diff_head_revision, diff_content_digest, target_summary, scan_dir, model, + reasoning_effort, status, phase, handoff_status, started_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + 'running', 'preflight', ?, ?, ?, ?) """, ( scan_id, @@ -199,6 +202,8 @@ def insert_running_scan( diff_target.get("contentDigest") if diff_target else None, target_summary, str(scan_dir), + optional_text(model, maximum=200), + optional_text(reasoning_effort, maximum=32), handoff_status, timestamp, timestamp, diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index b5c6a80a..1e49734e 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -31,7 +31,7 @@ const deepScanOwnershipProbe = [ "deep_scan.now = lambda: 'after'", "deep_scan.deep_scan_result = lambda database, value, *, start_disposition=None: {'startDisposition': start_disposition}", "try:", - " result = deep_scan.begin_deep_scan_for_scan(connection, scan_id, 'requesting-thread', argparse.Namespace(claim_token=case['suppliedToken']))", + " result = deep_scan.begin_deep_scan_for_scan(connection, scan_id, 'requesting-thread', argparse.Namespace(claim_token=case['suppliedToken'], model=None, reasoning_effort=None))", "except SystemExit as error:", " accepted, message, result = False, str(error), None", "else:", diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 77d93cd8..8bb89af6 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -937,4 +937,30 @@ describe("malformed scan artifact recovery", () => { await expect(completeScan(fixture)).rejects.toThrow("inventoryStrategy"); expect(await readFile(path, "utf8")).toBe(original); }); + + test.each(["complete-scan", "prepare-scan-completion"] as const)( + "keeps a repairable %s contract failure resumable", + async (command) => { + const fixture = await startDraftScan(); + const path = join(fixture.scanDir, "coverage.json"); + const document = await readJson(path); + const validInventoryStrategy = document.inventoryStrategy; + document.inventoryStrategy = ""; + await writeJson(path, document); + + await expect( + workbench(fixture, [command, "--scan-id", fixture.scanId]), + ).rejects.toThrow("inventoryStrategy"); + const pending = await workbench(fixture, [ + "get-scan", + "--scan-id", + fixture.scanId, + ]); + expect((pending["scan"] as ScanSummary).progress.status).toBe("running"); + + document.inventoryStrategy = validInventoryStrategy; + await writeJson(path, document); + expect((await completeScan(fixture)).findingCount).toBe(1); + }, + ); });