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
22 changes: 18 additions & 4 deletions sdk/typescript/_bundled_plugin/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
git_submodule_paths,
git_target_metadata,
git_worktree_context,
immutable_diff_content_digest,
require_git_worktree_head,
require_remediation_target,
require_scan_target_identity,
Expand Down Expand Up @@ -337,12 +338,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": immutable_diff_content_digest(kind, parent, head),
}
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": immutable_diff_content_digest(kind, base, head),
}


def inspect_setup_values(
Expand Down Expand Up @@ -504,8 +515,9 @@ 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"]:
target["snapshotDigest"] = scan["diff_content_digest"]
diff_target = stored_diff_target(scan)
if diff_target and diff_target.get("contentDigest"):
target["snapshotDigest"] = diff_target["contentDigest"]
else:
if scan["target_revision"] != "unversioned":
target["revision"] = scan["target_revision"]
Expand Down Expand Up @@ -1622,6 +1634,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"] = immutable_diff_content_digest("range", base, head)
mode = "diff" if diff_target is not None else recipe["mode"]
target_identity = scan_target_identity(repository, diff_target)
scope_file_count = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from workbench_target import (
directory_content_digest,
git_revision,
immutable_diff_content_digest,
worktree_content_digest,
)
from workbench_validation import optional_text, user_text
Expand Down Expand Up @@ -83,6 +84,14 @@ def stored_diff_target(row: sqlite3.Row) -> dict[str, str] | None:
}
if row["diff_content_digest"]:
target["contentDigest"] = row["diff_content_digest"]
elif (
target["kind"] in {"commit", "range"}
and target["baseRevision"]
and target["headRevision"]
):
target["contentDigest"] = immutable_diff_content_digest(
target["kind"], target["baseRevision"], target["headRevision"]
)
return target


Expand Down
8 changes: 8 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/workbench_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ def update_digest_field(digest: Any, label: bytes, value: bytes) -> None:
digest.update(value)


def immutable_diff_content_digest(kind: str, base_revision: str, head_revision: str) -> str:
payload = "\0".join(
("codex-security-diff/v1", kind, base_revision, head_revision)
).encode("utf-8")
digest = hashlib.sha256(payload).hexdigest()
return f"codex-security-snapshot/v1:sha256:{digest}"


def worktree_content_digest(target: Path) -> str:
require_clean_submodule_worktrees(target)
repository, pathspec = git_worktree_context(target)
Expand Down
241 changes: 232 additions & 9 deletions sdk/typescript/tests-ts/compact-diff-scan.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
chmodSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
Expand Down Expand Up @@ -71,6 +73,45 @@ function python(script: string, ...args: string[]) {
);
}

function pythonWithState(stateDir: string, script: string, ...args: string[]) {
const command =
Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py");
expect(command).not.toBeNull();
return spawnSync(
command!,
["-B", join(PLUGIN_ROOT, "scripts", script), ...args],
{
encoding: "utf8",
env: { ...process.env, CODEX_SECURITY_STATE_DIR: stateDir },
},
);
}

function pythonEval(source: string, ...args: string[]) {
const command =
Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py");
expect(command).not.toBeNull();
return spawnSync(command!, ["-I", "-B", "-c", source, ...args], {
encoding: "utf8",
});
}

function immutableDiffDigest(
kind: "commit" | "range",
baseRevision: string,
headRevision: string,
): string {
const digest = createHash("sha256")
.update("codex-security-diff/v1\0")
.update(kind)
.update("\0")
.update(baseRevision)
.update("\0")
.update(headRevision)
.digest("hex");
return `codex-security-snapshot/v1:sha256:${digest}`;
}

function candidate(path: string): JsonObject {
return {
cwe_ids: [],
Expand Down Expand Up @@ -281,6 +322,196 @@ describe("compact diff scan", () => {
expect(escaped.stderr).toContain("in-scope file row 1");
});

test("derives canonical digests for immutable and working-tree diffs", () => {
const { repository } = createRepository();
writeSource(repository, "app.ts", "export const value = 1;\n");
git(repository, "add", ".");
git(repository, "commit", "-qm", "first");
const first = git(repository, "rev-parse", "HEAD");
writeSource(repository, "app.ts", "export const value = 2;\n");
git(repository, "add", ".");
git(repository, "commit", "-qm", "second");
const second = git(repository, "rev-parse", "HEAD");
writeSource(repository, "app.ts", "export const value = 3;\n");
git(repository, "add", ".");
git(repository, "commit", "-qm", "third");
const third = git(repository, "rev-parse", "HEAD");

const inspect = (
kind: "commit" | "range" | "working_tree",
baseRevision?: string,
headRevision?: string,
): JsonObject => {
const args = [
"inspect-setup",
"--target-path",
repository,
"--scope",
".",
"--mode",
"diff",
"--diff-target-kind",
kind,
];
if (baseRevision !== undefined)
args.push("--diff-base-revision", baseRevision);
if (headRevision !== undefined)
args.push("--diff-head-revision", headRevision);
const result = python("workbench_db.py", ...args);
expect(result.status, result.stderr).toBe(0);
return (JSON.parse(result.stdout) as { diffTarget: JsonObject })
.diffTarget;
};

const commit = inspect("commit", second, third);
const range = inspect("range", second, third);
const repeatedRange = inspect("range", second, third);
const widerRange = inspect("range", first, third);
expect(commit["contentDigest"]).toBe(
immutableDiffDigest("commit", second, third),
);
expect(range["contentDigest"]).toBe(
immutableDiffDigest("range", second, third),
);
expect(repeatedRange["contentDigest"]).toBe(range["contentDigest"]);
expect(commit["contentDigest"]).not.toBe(range["contentDigest"]);
expect(widerRange["contentDigest"]).not.toBe(range["contentDigest"]);

writeSource(repository, "app.ts", "export const value = 4;\n");
const workingTree = inspect("working_tree");
const repeatedWorkingTree = inspect("working_tree");
expect(workingTree["contentDigest"]).toMatch(
/^codex-security-snapshot\/v1:sha256:[0-9a-f]{64}$/u,
);
expect(repeatedWorkingTree["contentDigest"]).toBe(
workingTree["contentDigest"],
);
writeSource(repository, "app.ts", "export const value = 5;\n");
expect(inspect("working_tree")["contentDigest"]).not.toBe(
workingTree["contentDigest"],
);
});

test("prepares CLI range completion with the canonical snapshot digest", () => {
const { root, repository } = createRepository();
writeSource(repository, "app.ts", "export const value = 1;\n");
git(repository, "add", ".");
git(repository, "commit", "-qm", "base");
const baseRevision = git(repository, "rev-parse", "HEAD");
writeSource(repository, "app.ts", "export const value = 2;\n");
git(repository, "add", ".");
git(repository, "commit", "-qm", "head");
const headRevision = git(repository, "rev-parse", "HEAD");
const stateDir = join(root, "state");
const scanDir = join(root, "scan");
mkdirSync(stateDir, { mode: 0o700 });
mkdirSync(scanDir, { mode: 0o700 });
chmodSync(stateDir, 0o700);
chmodSync(scanDir, 0o700);

const registration = pythonWithState(
stateDir,
"workbench_db.py",
"register-cli-scan",
"--repository",
repository,
"--scan-dir",
scanDir,
"--recipe-json",
JSON.stringify({
config: {},
mode: "standard",
repository,
target: {
kind: "refs",
paths: [],
base: baseRevision,
head: headRevision,
},
}),
);
expect(registration.status, registration.stderr).toBe(0);
const expectedDigest = immutableDiffDigest(
"range",
baseRevision,
headRevision,
);
const registered = JSON.parse(registration.stdout) as {
scanId: string;
contract: { diffTarget: { contentDigest?: string } };
};
const scanId = registered.scanId;
expect(registered.contract.diffTarget.contentDigest).toBe(expectedDigest);

const database = join(stateDir, "workbench.sqlite3");
const stored = pythonEval(
[
"import sqlite3, sys",
"connection = sqlite3.connect(sys.argv[1])",
"value = connection.execute('SELECT diff_content_digest FROM scans WHERE id = ?', (sys.argv[2],)).fetchone()[0]",
"print(value or '')",
].join("\n"),
database,
scanId,
);
expect(stored.status, stored.stderr).toBe(0);
expect(stored.stdout.trim()).toBe(expectedDigest);

const cleared = pythonEval(
[
"import sqlite3, sys",
"connection = sqlite3.connect(sys.argv[1])",
"connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (sys.argv[2],))",
"connection.commit()",
].join("\n"),
database,
scanId,
);
expect(cleared.status, cleared.stderr).toBe(0);

cpSync(join(PLUGIN_ROOT, "examples", "completed-scan"), scanDir, {
recursive: true,
});
const manifestPath = join(scanDir, "scan-manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
scan: {
id: string;
sealedAt?: string;
artifacts?: unknown;
target: JsonObject;
};
};
manifest.scan.id = scanId;
manifest.scan.target["kind"] = "git_diff";
delete manifest.scan.target["snapshotDigest"];
delete manifest.scan.sealedAt;
delete manifest.scan.artifacts;
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);

for (const name of ["findings.json", "coverage.json"]) {
const path = join(scanDir, name);
const artifact = JSON.parse(readFileSync(path, "utf8")) as JsonObject;
artifact["scanId"] = scanId;
writeFileSync(path, `${JSON.stringify(artifact)}\n`);
}
writeFileSync(join(scanDir, "report.md"), "# Draft report\n");

const prepared = pythonWithState(
stateDir,
"workbench_db.py",
"prepare-scan-completion",
"--scan-id",
scanId,
);
expect(prepared.status, prepared.stderr).toBe(0);
const target = (
JSON.parse(readFileSync(manifestPath, "utf8")) as {
scan: { target: JsonObject };
}
).scan.target;
expect(target["snapshotDigest"]).toBe(expectedDigest);
});

test("runs the compact MCP diff lifecycle through a completed scan", async () => {
const { root, repository } = createRepository();
writeSource(repository, "src/guard.py", "allowed = True\n");
Expand Down Expand Up @@ -407,16 +638,8 @@ 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}`,
immutableDiffDigest("range", baseRevision, headRevision),
);
expect((completed["coverage"] as JsonObject)["inventoryStrategy"]).toBe(
"diff",
Expand Down