diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 2019115f..9c630bdb 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -1,6 +1,8 @@ """Shared constants for the Codex Security workbench.""" import argparse +import os +from pathlib import Path MODES = ("diff", "standard", "deep") DIFF_TARGET_KINDS = ("working_tree", "commit", "range") @@ -68,6 +70,18 @@ "GIT_WORK_TREE", ) EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +EMPTY_GIT_TREES = { + "sha1": EMPTY_GIT_TREE, + "sha256": "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321", +} + + +def workbench_state_directory() -> Path: + configured = os.environ.get("CODEX_SECURITY_STATE_DIR") + if configured: + return Path(configured).expanduser().resolve() + codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() + return (codex_home / "state" / "plugins" / "codex-security").resolve() def main() -> None: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 1fc4ff72..b8f725e1 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -76,6 +76,7 @@ FINDINGS_RESULT_LIMIT, PATCH_PREVIEW_BYTES, SQLITE_RETRY_ATTEMPTS, + workbench_state_directory, ) from workbench_feedback import get_scan_feedback from workbench_remediation import remediation_claim_is_active @@ -100,10 +101,12 @@ from workbench_source_excerpt import finding_source_excerpt, safe_source_path from workbench_target import ( clean_worktree_content_digest, + committed_diff_content_digest, copy_directory_excluding, copy_git_worktree_files, directory_content_digest, directory_snapshot_regular_file_count, + empty_git_tree, git_command, git_output, git_revision, @@ -147,11 +150,7 @@ def stale_claim_before(seconds: int = CLAIM_LEASE_SECONDS) -> str: def state_dir() -> Path: - state_dir = os.environ.get("CODEX_SECURITY_STATE_DIR") - if state_dir: - return Path(state_dir).expanduser().resolve() - codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() - return (codex_home / "state" / "plugins" / "codex-security").resolve() + return workbench_state_directory() def database_path() -> Path: @@ -282,6 +281,35 @@ def resolve_git_commit(target: Path, revision: str, label: str) -> str: return resolved +def require_committed_diff_digest( + target: Path, + base: str, + head: str, + selected_digest: str | None, +) -> str: + current_digest = committed_diff_content_digest(target, base, head) + if selected_digest and selected_digest != current_digest: + raise SystemExit( + "The committed changes selected for review no longer produce the same " + "diff. Select the changes to review again." + ) + return current_digest + + +def revalidate_committed_diff_target( + target: Path, + diff_target: dict[str, str] | None, +) -> None: + if diff_target is None or diff_target["kind"] == "working_tree": + return + require_committed_diff_digest( + target, + diff_target["baseRevision"], + diff_target["headRevision"], + diff_target.get("contentDigest"), + ) + + def require_diff_target( target: Path, kind: str | None, @@ -322,7 +350,7 @@ def require_diff_target( None, ) if parent_line is None: - parent = EMPTY_GIT_TREE + parent = empty_git_tree(target) else: parent = resolve_git_commit( target, @@ -337,12 +365,22 @@ def require_diff_target( ) if supplied_base != parent: raise SystemExit("Commit base revision must match the selected commit's parent.") - return {"kind": kind, "baseRevision": parent, "headRevision": head} + return { + "kind": kind, + "baseRevision": parent, + "headRevision": head, + "contentDigest": require_committed_diff_digest(target, parent, head, content_digest), + } base = resolve_git_commit(target, base_revision or "", "Base revision") head = resolve_git_commit(target, head_revision or "", "Head revision") if base == head: raise SystemExit("Base and head revisions must identify different commits.") - return {"kind": kind, "baseRevision": base, "headRevision": head} + return { + "kind": kind, + "baseRevision": base, + "headRevision": head, + "contentDigest": require_committed_diff_digest(target, base, head, content_digest), + } def inspect_setup_values( @@ -504,7 +542,7 @@ def workbench_completion_binding(scan: sqlite3.Row, completed_at: str) -> dict[s if scan["mode"] == "diff": target["baseRevision"] = scan["diff_base_revision"] target["headRevision"] = scan["diff_head_revision"] - if scan["diff_target_kind"] == "working_tree" and scan["diff_content_digest"]: + if scan["diff_content_digest"]: target["snapshotDigest"] = scan["diff_content_digest"] else: if scan["target_revision"] != "unversioned": @@ -574,11 +612,11 @@ def verify_manifest_binding(scan: sqlite3.Row, manifest: dict[str, Any]) -> None ) if ( scan["diff_target_kind"] == "working_tree" - and target.get("snapshotDigest") != scan["diff_content_digest"] - ): + or scan["diff_content_digest"] is not None + ) and target.get("snapshotDigest") != scan["diff_content_digest"]: raise SystemExit( "scan-manifest.json target snapshotDigest must match the selected " - "working-tree contents." + "reviewed contents." ) scope = manifest_scan.get("scope") if not isinstance(scope, dict): @@ -885,6 +923,7 @@ def start_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict "This Codex thread already has an active Deep Scan for the selected " "target and scope. Rejoin that scan instead of starting another one." ) + revalidate_committed_diff_target(current_target, diff_target) insert_running_scan( connection, scan_id=scan_id, @@ -1040,6 +1079,7 @@ def _start_prompt_driven_scan( ), ) workspace = require_workspace(connection, workspace_id) + revalidate_committed_diff_target(target, diff_target) insert_running_scan( connection, scan_id=scan_id, @@ -1622,6 +1662,8 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) if head != current_head: raise SystemExit("Working-tree HEAD changed before the scan started.") diff_target["contentDigest"] = worktree_content_digest(repository) + else: + diff_target["contentDigest"] = committed_diff_content_digest(repository, base, head) mode = "diff" if diff_target is not None else recipe["mode"] target_identity = scan_target_identity(repository, diff_target) scope_file_count = ( @@ -1673,6 +1715,7 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) ), ) workspace = require_workspace(connection, workspace_id) + revalidate_committed_diff_target(repository, diff_target) insert_running_scan( connection, scan_id=scan_id, diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 05faf6e0..e9441deb 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -10,13 +10,19 @@ import stat import subprocess import sys +import tempfile from pathlib import Path -from typing import Any +from typing import IO, Any # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) from filesystem_identity import stored_filesystem_identity_matches -from workbench_constants import GIT_REPOSITORY_ENVIRONMENT +from workbench_constants import ( + EMPTY_GIT_TREE, + EMPTY_GIT_TREES, + GIT_REPOSITORY_ENVIRONMENT, + workbench_state_directory, +) def git_output( @@ -40,6 +46,78 @@ def git_bytes( return completed.stdout if completed.returncode == 0 else None +def git_digest_field( + digest: Any, + label: bytes, + target: Path, + *args: str, + git_dir: Path | None = None, + work_tree: Path | None = None, + stdin: IO[bytes] | None = None, + object_requests: IO[bytes] | None = None, +) -> bool: + """Hash length-framed Git output without buffering the entire output.""" + state_directory = workbench_state_directory() + state_directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryFile(dir=state_directory) as spool: + completed = git_command( + target, + *args, + text=False, + git_dir=git_dir, + work_tree=work_tree, + stdin=stdin, + stdout=spool, + ) + if completed.returncode != 0: + return False + if object_requests is not None: + spool.seek(0) + try: + write_committed_diff_object_requests(spool, object_requests) + except ValueError: + return False + object_requests.seek(0) + digest.update(len(label).to_bytes(4, "big")) + digest.update(label) + digest.update(os.fstat(spool.fileno()).st_size.to_bytes(8, "big")) + spool.seek(0) + for chunk in iter(lambda: spool.read(1024 * 1024), b""): + digest.update(chunk) + return True + + +def read_stream_nul_field(stream: IO[bytes]) -> bytes | None: + """Read one NUL-framed Git record without buffering the complete output.""" + field = bytearray() + while character := stream.read(1): + if character == b"\0": + return bytes(field) + field.extend(character) + if field: + raise ValueError("missing NUL terminator") + return None + + +def write_committed_diff_object_requests( + metadata: IO[bytes], + requests: IO[bytes], +) -> None: + """Collect changed blob objects from NUL-framed raw Git diff records.""" + while (header := read_stream_nul_field(metadata)) is not None: + if read_stream_nul_field(metadata) is None: + raise ValueError("missing raw diff path") + fields = header.split() + if len(fields) != 5 or not fields[0].startswith(b":"): + raise ValueError("invalid raw Git diff record") + for mode, object_name in ( + (fields[0][1:], fields[2]), + (fields[1], fields[3]), + ): + if mode not in {b"000000", b"160000"}: + requests.write(object_name + b"\n") + + def git_blob_bytes( target: Path, object_names: list[str], @@ -127,6 +205,8 @@ def git_command( input_data: str | bytes | None = None, git_dir: Path | None = None, work_tree: Path | None = None, + stdin: IO[bytes] | None = None, + stdout: IO[bytes] | None = None, ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: if (git_dir is None) != (work_tree is None): raise ValueError("git_dir and work_tree must be provided together") @@ -143,7 +223,9 @@ def git_command( return subprocess.run( full_command, check=False, - capture_output=True, + stdout=subprocess.PIPE if stdout is None else stdout, + stdin=stdin, + stderr=subprocess.PIPE, env=environment, text=text, input=input_data, @@ -155,6 +237,11 @@ def git_command( return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) +def empty_git_tree(target: Path) -> str: + object_format = git_output(target, "rev-parse", "--show-object-format") + return EMPTY_GIT_TREES.get(object_format or "", EMPTY_GIT_TREE) + + def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: digest.update(len(label).to_bytes(4, "big")) digest.update(label) @@ -168,6 +255,52 @@ def worktree_content_digest(target: Path) -> str: return worktree_content_digest_for_context(repository, pathspec) +def committed_diff_arguments(base: str, head: str, pathspec: str) -> tuple[str, ...]: + return ( + "-c", + f"diff.orderFile={os.devnull}", + "diff", + "--raw", + "-z", + "--no-abbrev", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--no-relative", + "--no-renames", + "--ignore-submodules=none", + base, + head, + "--", + pathspec, + ) + + +def committed_diff_content_digest(target: Path, base: str, head: str) -> str: + repository, pathspec = git_worktree_context(target) + digest = hashlib.sha256() + update_digest_field(digest, b"format", b"codex-security-snapshot/v1") + state_directory = workbench_state_directory() + state_directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryFile(dir=state_directory) as object_requests: + if not git_digest_field( + digest, + b"tracked-diff", + repository, + *committed_diff_arguments(base, head, pathspec), + object_requests=object_requests, + ) or not git_digest_field( + digest, + b"tracked-objects", + repository, + "cat-file", + "--batch", + stdin=object_requests, + ): + raise SystemExit("Could not snapshot the selected committed changes.") + return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" + + def worktree_content_digest_for_context( repository: Path, pathspec: str, @@ -605,10 +738,30 @@ def require_git_worktree_head(target: Path) -> str: def scan_target_warning(scan: sqlite3.Row) -> str | None: - if scan["diff_target_kind"] != "working_tree" and not scan["target_snapshot_digest"]: + committed_diff = scan["diff_target_kind"] in {"commit", "range"} + if ( + not committed_diff + and scan["diff_target_kind"] != "working_tree" + and not scan["target_snapshot_digest"] + ): + return None + if committed_diff and not scan["diff_content_digest"]: return None try: target = require_scan_target_identity(scan) + if committed_diff: + expected_digest = scan["diff_content_digest"] + current_digest = committed_diff_content_digest( + target, + scan["diff_base_revision"], + scan["diff_head_revision"], + ) + if current_digest != expected_digest: + return ( + "Committed changes changed while the scan was running; " + "results were saved for the original snapshot." + ) + return None if scan["target_revision"] == "unversioned": if ( directory_content_digest(target, excluded=(Path(scan["scan_dir"]),)) diff --git a/sdk/typescript/tests-ts/compact-diff-scan.test.ts b/sdk/typescript/tests-ts/compact-diff-scan.test.ts index 02982272..d3e63d30 100644 --- a/sdk/typescript/tests-ts/compact-diff-scan.test.ts +++ b/sdk/typescript/tests-ts/compact-diff-scan.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, @@ -458,7 +458,13 @@ describe("compact diff scan", () => { diffTarget: { kind: "range", baseRevision, headRevision }, }; const opened = await call("open_codex_security_workspace", selection); - const sessionId = (opened["workspace"] as JsonObject)["id"] as string; + const openedWorkspace = opened["workspace"] as JsonObject; + const selectedDiffTarget = openedWorkspace["diffTarget"] as JsonObject; + const contentDigest = selectedDiffTarget["contentDigest"] as string; + expect(contentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + const sessionId = openedWorkspace["id"] as string; await call("submit_codex_security_setup", { ...selection, sessionId }); const started = await call("start_codex_security_scan", { sessionId }); const results = (started["workspace"] as JsonObject)[ @@ -542,17 +548,7 @@ describe("compact diff scan", () => { const target = ( (completed["manifest"] as JsonObject)["scan"] as JsonObject )["target"] as JsonObject; - const digest = createHash("sha256") - .update("codex-security-diff/v1\0") - .update("range") - .update("\0") - .update(baseRevision) - .update("\0") - .update(headRevision) - .digest("hex"); - expect(target["snapshotDigest"]).toBe( - `codex-security-snapshot/v1:sha256:${digest}`, - ); + expect(target["snapshotDigest"]).toBe(contentDigest); expect((completed["coverage"] as JsonObject)["inventoryStrategy"]).toBe( "diff", ); diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index fa2c7c91..477f9039 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { cp, mkdir, @@ -116,8 +117,21 @@ async function workbench(fixture: ScanFixture, args: readonly string[]) { ); } +function fixtureGit(repository: string, ...args: string[]): string { + const result = spawnSync("git", ["-C", repository, ...args], { + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); +} + async function startDraftScan( - repositoryKind: "directory" | "clean" | "dirty" | "nested" = "directory", + repositoryKind: + | "directory" + | "clean" + | "dirty" + | "nested" + | "committed" = "directory", ): Promise { const root = await realpath( await mkdtemp(join(tmpdir(), "codex-security-scan-recovery-")), @@ -131,6 +145,7 @@ async function startDraftScan( await mkdir(join(target, "src"), { recursive: true }); await writeFile(join(target, "src", "extract.py"), "# fixture\n"); await mkdir(scanDir, { mode: 0o700 }); + let committedDiff: { base: string; head: string } | undefined; if (repositoryKind !== "directory") { for (const args of [ @@ -152,6 +167,26 @@ async function startDraftScan( const result = spawnSync("git", args, { encoding: "utf8" }); expect(result.status, result.stderr).toBe(0); } + if (repositoryKind === "committed") { + const base = fixtureGit(target, "rev-parse", "HEAD"); + await writeFile(join(target, "src", "extract.py"), "# changed fixture\n"); + fixtureGit(target, "add", "--", "src/extract.py"); + fixtureGit( + target, + "-c", + "user.name=Codex Security", + "-c", + "user.email=codex-security@example.invalid", + "commit", + "--quiet", + "-m", + "committed changes", + ); + committedDiff = { + base, + head: fixtureGit(target, "rev-parse", "HEAD"), + }; + } if (repositoryKind === "dirty") { await writeFile(join(target, "src", "extract.py"), "# changed fixture\n"); } @@ -185,7 +220,10 @@ async function startDraftScan( config: {}, mode: "standard", repository: target, - target: { kind: "repository", paths: [] }, + target: + committedDiff === undefined + ? { kind: "repository", paths: [] } + : { kind: "refs", paths: [], ...committedDiff }, }), ]); fixture.scanId = String(registration["scanId"]); @@ -198,32 +236,86 @@ async function startDraftScan( const manifest = await readJson<{ scan: { id: string; - target: { kind: string }; + target: { kind: string } & Record; sealedAt?: string; artifacts?: unknown[]; }; }>(manifestPath); manifest.scan.id = fixture.scanId; - manifest.scan.target.kind = - repositoryKind === "directory" - ? "directory_snapshot" - : repositoryKind === "clean" - ? "git_revision" - : "git_worktree"; + if (committedDiff === undefined) { + manifest.scan.target.kind = + repositoryKind === "directory" + ? "directory_snapshot" + : repositoryKind === "clean" + ? "git_revision" + : "git_worktree"; + } else { + manifest.scan.target = { + kind: "git_diff", + targetId: manifest.scan.target["targetId"], + displayName: manifest.scan.target["displayName"], + baseRevision: committedDiff.base, + headRevision: committedDiff.head, + }; + } delete manifest.scan.sealedAt; delete manifest.scan.artifacts; await writeJson(manifestPath, manifest); for (const name of ["findings.json", "coverage.json"] as const) { const path = join(scanDir, name); - const document = await readJson<{ scanId: string }>(path); + const document = await readJson<{ scanId: string; mode?: string }>(path); document.scanId = fixture.scanId; + if (committedDiff !== undefined && name === "coverage.json") { + document.mode = "branch_diff"; + } await writeJson(path, document); } await writeFile(join(scanDir, "report.md"), "# Draft report\n"); return fixture; } +function committedDiffTarget(fixture: ScanFixture): { + baseRevision: string; + headRevision: string; + contentDigest: string; +} { + const contract = fixture.registration["contract"] as { + diffTarget: { + baseRevision: string; + headRevision: string; + contentDigest: string; + }; + }; + expect(contract.diffTarget.contentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + return contract.diffTarget; +} + +async function replaceCommittedDiffHead(fixture: ScanFixture): Promise { + const { headRevision } = committedDiffTarget(fixture); + await writeFile( + join(fixture.repository, "src", "extract.py"), + "# substituted after selection\n", + ); + fixtureGit(fixture.repository, "add", "--", "src/extract.py"); + fixtureGit( + fixture.repository, + "-c", + "user.name=Codex Security", + "-c", + "user.email=codex-security@example.invalid", + "commit", + "--quiet", + "-m", + "replacement", + ); + const replacement = fixtureGit(fixture.repository, "rev-parse", "HEAD"); + fixtureGit(fixture.repository, "reset", "--hard", headRevision); + fixtureGit(fixture.repository, "replace", "-f", headRevision, replacement); +} + async function completeScan(fixture: ScanFixture): Promise { const result = await workbench(fixture, [ "complete-scan", @@ -437,6 +529,579 @@ describe("malformed scan artifact recovery", () => { } }); + test("seals a committed diff with its registered content digest", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + + expect((await completeScan(fixture)).progress.status).toBe("complete"); + + const sealed = await readJson<{ + scan: { + target: { + kind: string; + baseRevision: string; + headRevision: string; + snapshotDigest: string; + }; + }; + }>(join(fixture.scanDir, "scan-manifest.json")); + expect(sealed.scan.target).toMatchObject({ + kind: "git_diff", + baseRevision: selected.baseRevision, + headRevision: selected.headRevision, + snapshotDigest: selected.contentDigest, + }); + }); + + test("rejects committed diff replacements before the selected scan starts", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + await replaceCommittedDiffHead(fixture); + + await expect( + workbench(fixture, [ + "inspect-setup", + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + selected.headRevision, + "--diff-content-digest", + selected.contentDigest, + ]), + ).rejects.toThrow("no longer produce the same diff"); + }); + + test("rejects replaced Git blob contents in a committed diff", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + const original = fixtureGit( + fixture.repository, + "rev-parse", + `${selected.headRevision}:src/extract.py`, + ); + const replacement = spawnSync( + "git", + ["-C", fixture.repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "# replaced blob contents\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + fixtureGit( + fixture.repository, + "replace", + "-f", + original, + replacement.stdout.trim(), + ); + + await expect( + workbench(fixture, [ + "inspect-setup", + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + selected.headRevision, + "--diff-content-digest", + selected.contentDigest, + ]), + ).rejects.toThrow("no longer produce the same diff"); + }); + + test.each(["workspace", "cli"] as const)( + "rejects committed diff replacements during %s scan registration", + async (scenario) => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + await replaceCommittedDiffHead(fixture); + const replacement = fixtureGit( + fixture.repository, + "rev-parse", + `refs/replace/${selected.headRevision}`, + ); + fixtureGit(fixture.repository, "replace", "-d", selected.headRevision); + + const workspaceId = randomUUID(); + const scanDirectory = join(fixture.stateDir, "registration-race-scan"); + await mkdir(scanDirectory, { mode: 0o700 }); + if (scenario === "workspace") { + await workbench(fixture, [ + "create-workspace", + "--workspace-id", + workspaceId, + ]); + await workbench(fixture, [ + "save-workspace", + "--workspace-id", + workspaceId, + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + selected.headRevision, + "--diff-content-digest", + selected.contentDigest, + ]); + } + + const probe = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import argparse, json, subprocess, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "repository = Path(sys.argv[2])", + "head, replacement, workspace_id = sys.argv[3:6]", + "scan_directory, scenario, base = sys.argv[6:9]", + "original_count = workbench.directory_snapshot_regular_file_count", + "def replace_during_count(path):", + " count = original_count(path)", + " subprocess.run(['git', '-C', str(repository), 'replace', '-f', head, replacement], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)", + " return count", + "workbench.directory_snapshot_regular_file_count = replace_during_count", + "if scenario == 'workspace':", + " arguments = argparse.Namespace(workspace_id=workspace_id, scan_root=None, model=None, reasoning_effort=None)", + " start = workbench.start_scan", + "else:", + " recipe = {'config': {}, 'mode': 'standard', 'repository': str(repository), 'target': {'kind': 'refs', 'paths': [], 'base': base, 'head': head}}", + " arguments = argparse.Namespace(repository=str(repository), scan_dir=scan_directory, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + " start = workbench.register_cli_scan", + "with workbench.connect() as connection:", + " previous = connection.execute('SELECT COUNT(*) FROM scans').fetchone()[0]", + " try:", + " start(connection, arguments)", + " except SystemExit as exception:", + " error = str(exception)", + " else:", + " error = None", + " created = connection.execute('SELECT COUNT(*) FROM scans').fetchone()[0] - previous", + "print(json.dumps({'error': error, 'createdScans': created}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + selected.headRevision, + replacement, + workspaceId, + scanDirectory, + scenario, + selected.baseRevision, + ], + { + encoding: "utf8", + env: { + PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: fixture.stateDir, + }, + }, + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toMatchObject({ + error: expect.stringContaining("no longer produce the same diff"), + createdScans: 0, + }); + }, + ); + + test("warns when committed diff contents change before scan completion", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + await replaceCommittedDiffHead(fixture); + + const warning = + "Committed changes changed while the scan was running; results were saved for the original snapshot."; + const completed = await workbench(fixture, [ + "complete-scan", + "--scan-id", + fixture.scanId, + ]); + + expect(completed["targetWarnings"]).toEqual([warning]); + expect((completed["scan"] as ScanSummary).warnings).toContain(warning); + const sealed = await readJson<{ + scan: { target: { snapshotDigest: string } }; + }>(join(fixture.scanDir, "scan-manifest.json")); + expect(sealed.scan.target.snapshotDigest).toBe(selected.contentDigest); + }); + + test("keeps committed snapshots stable when Git diff rendering settings change", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + + for (const [setting, value] of [ + ["diff.noprefix", "true"], + ["diff.mnemonicprefix", "true"], + ["diff.relative", "true"], + ["diff.renames", "true"], + ["diff.algorithm", "histogram"], + ["diff.indentheuristic", "true"], + ["diff.context", "9"], + ["diff.interhunkcontext", "5"], + ["diff.suppressblankempty", "true"], + ["diff.submodule", "log"], + ["core.quotepath", "false"], + ["color.ui", "always"], + ] as const) { + fixtureGit(fixture.repository, "config", setting, value); + } + + const inspected = await workbench(fixture, [ + "inspect-setup", + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + selected.headRevision, + "--diff-content-digest", + selected.contentDigest, + ]); + + expect(inspected["diffTarget"]).toMatchObject({ + contentDigest: selected.contentDigest, + }); + expect((await completeScan(fixture)).warnings).toEqual([]); + }); + + test("keeps committed snapshots stable when Git attribute sources change", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + await writeFile( + join(fixture.repository, ".gitattributes"), + "src/extract.py binary\n", + ); + const globalAttributes = join(fixture.stateDir, "global.attributes"); + await writeFile(globalAttributes, "src/extract.py binary\n"); + fixtureGit( + fixture.repository, + "config", + "core.attributesFile", + globalAttributes, + ); + await writeFile( + join(fixture.repository, ".git", "info", "attributes"), + "src/extract.py binary\n", + ); + + const inspected = await workbench(fixture, [ + "inspect-setup", + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + selected.headRevision, + "--diff-content-digest", + selected.contentDigest, + ]); + + expect(inspected["diffTarget"]).toMatchObject({ + contentDigest: selected.contentDigest, + }); + expect((await completeScan(fixture)).warnings).toEqual([]); + }); + + test("hashes committed diffs without newer Git attribute-source options", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + const probe = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_target as target", + "repository, base, head = Path(sys.argv[2]), sys.argv[3], sys.argv[4]", + "commands = []", + "original = target.git_command", + "def supported_git(where, *args, **options):", + " if any(argument.startswith('--attr-source') for argument in args):", + " raise AssertionError('Git 2.39 does not support --attr-source')", + " commands.append(args)", + " return original(where, *args, **options)", + "target.git_command = supported_git", + "digest = target.committed_diff_content_digest(repository, base, head)", + "print(json.dumps({'digest': digest, 'commands': commands}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + selected.baseRevision, + selected.headRevision, + ], + { + encoding: "utf8", + env: { + PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: fixture.stateDir, + }, + }, + ); + + expect(probe.status, probe.stderr).toBe(0); + const result = JSON.parse(probe.stdout) as { + digest: string; + commands: string[][]; + }; + expect(result.digest).toBe(selected.contentDigest); + expect(result.commands.some((command) => command.includes("diff"))).toBe( + true, + ); + }); + + test("keeps committed snapshots stable when named Git diff drivers change", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + await writeFile( + join(fixture.repository, ".gitattributes"), + "src/extract.py diff=special\n", + ); + fixtureGit(fixture.repository, "add", "--", ".gitattributes"); + fixtureGit( + fixture.repository, + "-c", + "user.name=Codex Security", + "-c", + "user.email=codex-security@example.invalid", + "commit", + "--quiet", + "-m", + "versioned diff driver", + ); + const headRevision = fixtureGit(fixture.repository, "rev-parse", "HEAD"); + const arguments_ = [ + "inspect-setup", + "--target-path", + fixture.repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "range", + "--diff-base-revision", + selected.baseRevision, + "--diff-head-revision", + headRevision, + ]; + const original = await workbench(fixture, arguments_); + const contentDigest = (original["diffTarget"] as { contentDigest: string }) + .contentDigest; + + fixtureGit(fixture.repository, "config", "diff.special.binary", "true"); + const inspected = await workbench(fixture, [ + ...arguments_, + "--diff-content-digest", + contentDigest, + ]); + + expect(inspected["diffTarget"]).toMatchObject({ contentDigest }); + }); + + test("hashes committed diff output without buffering the Git patch", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + const probe = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import hashlib, io, json, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_target as target", + "repository, base, head = Path(sys.argv[2]), sys.argv[3], sys.argv[4]", + "calls = []", + "original = target.git_bytes", + "def record(where, *args, **options):", + " calls.append(args)", + " return original(where, *args, **options)", + "target.git_bytes = record", + "streamed = target.committed_diff_content_digest(repository, base, head)", + "target.git_bytes = original", + "root, pathspec = target.git_worktree_context(repository)", + "metadata = original(root, *target.committed_diff_arguments(base, head, pathspec))", + "requests = io.BytesIO()", + "target.write_committed_diff_object_requests(io.BytesIO(metadata), requests)", + "objects = target.git_command(root, 'cat-file', '--batch', text=False, input_data=requests.getvalue()).stdout", + "digest = hashlib.sha256()", + "target.update_digest_field(digest, b'format', b'codex-security-snapshot/v1')", + "target.update_digest_field(digest, b'tracked-diff', metadata)", + "target.update_digest_field(digest, b'tracked-objects', objects)", + "print(json.dumps({'streamed': streamed, 'buffered': 'codex-security-snapshot/v1:sha256:' + digest.hexdigest(), 'bufferedDiffCalls': [args for args in calls if 'diff' in args]}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + selected.baseRevision, + selected.headRevision, + ], + { + encoding: "utf8", + env: { + PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: fixture.stateDir, + }, + }, + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toEqual({ + streamed: selected.contentDigest, + buffered: selected.contentDigest, + bufferedDiffCalls: [], + }); + }); + + test("keeps committed diff spooling in the approved workbench state directory", async () => { + const fixture = await startDraftScan("committed"); + const selected = committedDiffTarget(fixture); + const probe = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "import workbench_target as target", + "repository, base, head = Path(sys.argv[2]), sys.argv[3], sys.argv[4]", + "approved = Path(sys.argv[5]).resolve()", + "directories = []", + "original = target.tempfile.TemporaryFile", + "def restricted_temporary(*args, **options):", + " directory = options.get('dir')", + " if directory is None or Path(directory).resolve() != approved:", + " raise PermissionError('temporary files outside the workbench state directory are denied')", + " directories.append(str(Path(directory).resolve()))", + " return original(*args, **options)", + "target.tempfile.TemporaryFile = restricted_temporary", + "digest = target.committed_diff_content_digest(repository, base, head)", + "print(json.dumps({'digest': digest, 'spoolDirectories': directories}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + selected.baseRevision, + selected.headRevision, + fixture.stateDir, + ], + { + encoding: "utf8", + env: { + PATH: process.env["PATH"], + CODEX_SECURITY_STATE_DIR: fixture.stateDir, + }, + }, + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toEqual({ + digest: selected.contentDigest, + spoolDirectories: [fixture.stateDir, fixture.stateDir, fixture.stateDir], + }); + }); + + test("selects root commits in SHA-1 and supported SHA-256 repositories", async () => { + const fixture = await startDraftScan("committed"); + const emptyTrees = { + sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + sha256: + "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321", + }; + + for (const objectFormat of ["sha1", "sha256"] as const) { + const repository = join(fixture.stateDir, `root-${objectFormat}`); + await mkdir(repository, { recursive: true }); + const initialized = spawnSync( + "git", + ["init", "--quiet", `--object-format=${objectFormat}`, repository], + { encoding: "utf8" }, + ); + if (objectFormat === "sha256" && initialized.status !== 0) continue; + expect(initialized.status, initialized.stderr).toBe(0); + await writeFile(join(repository, "source.txt"), "root fixture\n"); + fixtureGit(repository, "add", "--", "source.txt"); + fixtureGit( + repository, + "-c", + "user.name=Codex Security", + "-c", + "user.email=codex-security@example.invalid", + "commit", + "--quiet", + "-m", + "root", + ); + const headRevision = fixtureGit(repository, "rev-parse", "HEAD"); + const inspected = await workbench(fixture, [ + "inspect-setup", + "--target-path", + repository, + "--scope", + ".", + "--mode", + "diff", + "--diff-target-kind", + "commit", + "--diff-head-revision", + headRevision, + ]); + + expect(inspected["diffTarget"]).toMatchObject({ + kind: "commit", + baseRevision: emptyTrees[objectFormat], + headRevision, + contentDigest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + }); + } + }); + test("seals a prepared scan without publishing it before acceptance", async () => { const fixture = await startDraftScan();