diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 38e6b830b..d7814cfc5 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.76", + "version": "0.1.77", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py index 42bb48dc6..e9f911975 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -167,17 +167,23 @@ def generate_diff_in_scope_files( ) -> int: """Reuse the existing diff selection without generating previews or duplicate worklists.""" sys.path.insert(0, str(Path(__file__).resolve().parent)) - from generate_rank_input import git_changed_paths, path_is_excluded + from finalize_scan_contract import scan_root_identity + from generate_rank_input import ( + changed_path_parent_is_within_target, + git_changed_paths, + path_is_excluded, + preview_for_changed_path, + ) from rank_preview import ( DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, is_binary_sample, - preview_for, ) from workbench_target import git_blob_bytes rows: list[bytes] = [] try: + root_identity = scan_root_identity(repository)[1] if mode != "revisions" else None changed = ( committed_changed_paths(repository, base, head) if mode == "revisions" @@ -206,6 +212,19 @@ def generate_diff_in_scope_files( for path, status in eligible: relative = path.relative_to(repository) + if mode != "revisions": + try: + within_target = changed_path_parent_is_within_target( + path, repository + ) + except (OSError, RuntimeError) as error: + raise InventoryError( + "could not inspect a changed Git working-tree path" + ) from error + if not within_target: + raise InventoryError( + "changed Git working-tree paths must stay inside the selected target" + ) if status != "D": if mode == "revisions": contents = revision_blobs[relative] @@ -215,12 +234,24 @@ def generate_diff_in_scope_files( ) if is_binary_sample(contents): continue - elif ( - path.is_symlink() - or not path.is_file() - or preview_for(path, DEFAULT_PREVIEW_BYTES)[1] - ): + elif path.is_symlink() or not path.is_file(): continue + else: + try: + _, is_binary = preview_for_changed_path( + path, + repository, + DEFAULT_PREVIEW_BYTES, + expected_root_identity=root_identity, + ) + except (FileNotFoundError, PermissionError): + continue + except (OSError, RuntimeError, ValueError) as error: + raise InventoryError( + "changed Git working-tree paths must stay inside the selected target" + ) from error + if is_binary: + continue relative_path = relative.as_posix() if "\n" in relative_path or "\r" in relative_path: raise InventoryError( diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 8c4dd3722..f64166171 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -25,10 +25,12 @@ from __future__ import annotations import argparse +import errno import hashlib import json import os import re +import stat import subprocess import sys from collections import Counter @@ -37,9 +39,17 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) +from finalize_scan_contract import ( + _descriptor_relative_reads_available, + _open_scan_local_directory, + _open_verified_scan_directory, + _windows_scan_local_files, + scan_root_identity, +) from rank_preview import ( DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, + is_binary_sample, preview_for, preview_for_bytes, ) @@ -293,6 +303,121 @@ def path_is_excluded(path: Path) -> bool: return path.name.endswith((".min.js", ".map")) +def changed_path_parent_is_within_target(path: Path, target: Path) -> bool: + """Resolve the nearest existing parent without dereferencing the changed leaf.""" + target = target.resolve(strict=True) + candidate = path.parent + while True: + try: + candidate.lstat() + except (FileNotFoundError, NotADirectoryError): + parent = candidate.parent + if parent == candidate: + return False + candidate = parent + continue + break + + resolved = candidate.resolve(strict=True) + if resolved.is_relative_to(target): + return True + for ancestor in (resolved, *resolved.parents): + try: + if ancestor.samefile(target): + return True + except OSError: + continue + return False + + +def _open_windows_changed_path_descriptor( + target: Path, relative_path: Path, expected_root_identity: tuple[int, int] | None +) -> int: + """Preserve ordinary missing-leaf behavior without relaxing parent checks.""" + + expected_path = target / relative_path + try: + return _windows_scan_local_files().open_read_fd( + target, + relative_path.as_posix(), + "changed Git working-tree path", + expected_root_identity=expected_root_identity, + ) + except OSError as error: + if error.filename is None or os.path.normcase( + os.path.normpath(os.fspath(error.filename)) + ) != os.path.normcase(os.path.normpath(os.fspath(expected_path))): + raise + if error.errno in {errno.ENOENT, 3}: + raise FileNotFoundError( + error.errno, error.strerror, error.filename + ) from error + if error.errno in {errno.EACCES, 5, 32, 33}: + raise PermissionError(error.errno, error.strerror, error.filename) from error + raise + + +def preview_for_changed_path( + path: Path, + target: Path, + preview_bytes: int, + *, + expected_root_identity: tuple[int, int] | None, +) -> tuple[str, bool]: + """Bind working-tree reads to the checked repository and parent identities.""" + + try: + relative_parent = path.parent.resolve(strict=True).relative_to(target) + except (FileNotFoundError, PermissionError) as error: + raise ValueError("changed Git working-tree parent became unavailable") from error + descriptor: int | None = None + try: + if os.name == "nt": + descriptor = _open_windows_changed_path_descriptor( + target, relative_parent / path.name, expected_root_identity + ) + elif _descriptor_relative_reads_available(): + root_descriptor = _open_verified_scan_directory(target, expected_root_identity) + try: + try: + parent_descriptor = _open_scan_local_directory( + root_descriptor, relative_parent.parts, create=False + ) + except (FileNotFoundError, PermissionError) as error: + raise ValueError( + "changed Git working-tree parent became unavailable" + ) from error + try: + descriptor = os.open( + path.name, + os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0), + dir_fd=parent_descriptor, + ) + finally: + os.close(parent_descriptor) + finally: + os.close(root_descriptor) + + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise OSError("changed Git working-tree path is not a regular file") + else: + raise OSError("changed Git working-tree input requires secure file operations") + + try: + with os.fdopen(descriptor, "rb") as source: + descriptor = None + sample = source.read(4096) + if is_binary_sample(sample): + return "", True + data = sample + source.read() + except OSError: + return "", True + return preview_for_bytes(path, data, preview_bytes) + finally: + if descriptor is not None: + os.close(descriptor) + + def windows_stream_component(path: Path) -> str | None: """Return the first NTFS alternate-data-stream component.""" @@ -684,6 +809,7 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: if not repo.is_dir(): raise SystemExit(f"Repo path not found: {repo}") + root_identity = scan_root_identity(repo)[1] if args.mode != "revisions" else None changed = [ (path, status) for path, status in git_changed_paths(repo, args.base, args.head, args.mode) @@ -708,6 +834,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rows: list[JsonRow] = [] for path, status in changed: rel = path.relative_to(repo) + if args.mode != "revisions": + try: + within_target = changed_path_parent_is_within_target(path, repo) + except (OSError, RuntimeError) as error: + raise SystemExit( + "Could not inspect a changed Git working-tree path." + ) from error + if not within_target: + raise SystemExit( + "Changed Git working-tree paths must stay inside the selected target." + ) if status == "D": preview = "" @@ -724,13 +861,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: preview = "" elif path.is_file(): try: - path.resolve(strict=True).relative_to(repo) - except (OSError, ValueError): - preview = "" - else: - preview, is_binary = preview_for(path, args.preview_bytes) - if is_binary: - continue + preview, is_binary = preview_for_changed_path( + path, repo, args.preview_bytes, expected_root_identity=root_identity + ) + except (FileNotFoundError, PermissionError): + continue + except (OSError, RuntimeError, ValueError) as error: + raise SystemExit( + "Changed Git working-tree paths must stay inside the selected target." + ) from error + if is_binary: + continue else: preview = "" rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) diff --git a/sdk/typescript/_bundled_plugin/scripts/windows_scan_local_files.py b/sdk/typescript/_bundled_plugin/scripts/windows_scan_local_files.py index b89c6ef8b..62d356979 100644 --- a/sdk/typescript/_bundled_plugin/scripts/windows_scan_local_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/windows_scan_local_files.py @@ -458,11 +458,22 @@ def scan_root_identity(scan_dir: Path) -> tuple[Path, tuple[int, int]]: return root_path, (metadata.st_dev, metadata.st_ino) -def open_read_fd(scan_dir: Path, relative_path: str, context: str) -> int: +def open_read_fd( + scan_dir: Path, + relative_path: str, + context: str, + *, + expected_root_identity: tuple[int, int] | None = None, +) -> int: """Open a verified regular file and return an owned binary read descriptor.""" try: - with _locked_parent(scan_dir, relative_path, create=False) as (parent_path, leaf_name): + with _locked_parent( + scan_dir, + relative_path, + create=False, + expected_root_identity=expected_root_identity, + ) as (parent_path, leaf_name): path = parent_path / leaf_name handle = _create_file( path, diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 35a3bcd2d..b6d204c25 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.76" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.77" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 19e120300..8dccaa752 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -4,8 +4,10 @@ import { mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,6 +17,26 @@ import { PLUGIN_ROOT } from "./plugin-root.js"; const temporaryRoots: string[] = []; +function supportsFileSymlinks(): boolean { + const root = mkdtempSync(join(tmpdir(), "codex-security-symlink-probe-")); + try { + symlinkSync("missing.py", join(root, "broken.py"), "file"); + return true; + } catch (error) { + if ( + process.platform === "win32" && + (error as NodeJS.ErrnoException).code === "EPERM" + ) { + return false; + } + throw error; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const fileSymlinksAvailable = supportsFileSymlinks(); + function pythonExecutable(): string | null { return ( process.env["PYTHON"] ?? @@ -44,19 +66,361 @@ function git(repository: string, ...args: string[]): string { ).trim(); } -test("diff previews stay inside the selected repository", () => { +function stagedBrokenSymlinkFixture(): { + root: string; + repository: string; + base: string; +} { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-diff-broken-link-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + mkdirSync(repository); + git(repository, "init", "-q"); + writeFileSync(join(repository, "base.py"), "value = 1\n"); + git(repository, "add", "base.py"); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + symlinkSync( + "../synthetic-fixture/missing-target.py", + join(repository, "broken.py"), + "file", + ); + git(repository, "add", "broken.py"); + return { root, repository, base }; +} + +test.skipIf(!fileSymlinksAvailable)( + "diff inventory omits a broken symlink leaf", + () => { + const { root, repository, base } = stagedBrokenSymlinkFixture(); + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const output = join(root, "in-scope-files.txt"); + const result = spawnSync( + python!, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + "--diff-base", + base, + "--diff-mode", + "local-patch", + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(output, "utf8")).toBe(""); + }, +); + +test.skipIf(!fileSymlinksAvailable)( + "diff rank input keeps a broken symlink leaf without a preview", + () => { + const { root, repository, base } = stagedBrokenSymlinkFixture(); + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const output = join(root, "rank-input.jsonl"); + const result = spawnSync( + python!, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + base, + "--mode", + "local-patch", + "--out", + output, + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8"))).toEqual({ + path: "broken.py", + area: "diff", + preview: "", + }); + }, +); + +test.each(["parent", "repository"])( + "rejects %s replacement after local-diff confinement is checked", + (replacement) => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-diff-parent-race-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + const parent = join(repository, "src"); + const filename = + process.platform === "win32" ? "handler.py" : "handler:local.py"; + mkdirSync(parent, { recursive: true }); + git(repository, "init", "-q"); + writeFileSync(join(parent, filename), "inside = 1\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(parent, filename), "inside = 2\n"); + + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const scripts = join(PLUGIN_ROOT, "scripts"); + const probe = [ + "import os, sys", + "scripts, repository, base, external, parked, output, generator, replacement = sys.argv[1:]", + "sys.path.insert(0, scripts)", + "import generate_rank_input as ranking", + "checked_parent = ranking.changed_path_parent_is_within_target", + "def swap_parent(path, target):", + " accepted = checked_parent(path, target)", + " if replacement == 'repository':", + " os.replace(target, parked)", + " os.replace(external, target)", + " return accepted", + " os.replace(path.parent, parked)", + " if os.name == 'nt':", + " import _winapi", + " _winapi.CreateJunction(external, str(path.parent))", + " else:", + " os.symlink(external, path.parent, target_is_directory=True)", + " return accepted", + "if generator.startswith('safe-'):", + " generator = generator.removeprefix('safe-')", + "else:", + " ranking.changed_path_parent_is_within_target = swap_parent", + "if generator == 'ranking':", + " sys.argv = ['ranking', 'make-diff-rank-input', '--repo', repository, '--base', base, '--mode', 'local-patch', '--out', output]", + " ranking.main()", + "else:", + " import generate_in_scope_files as inventory", + " sys.argv = ['inventory', '--repo', repository, '--scope', '.', '--out', output, '--diff-base', base, '--diff-mode', 'local-patch']", + " inventory.main()", + ].join("\n"); + + for (const generator of ["ranking", "inventory"]) { + const external = join(root, `external-${generator}`); + const parked = join(root, `parked-${generator}`); + const output = join(root, `${generator}.output`); + const externalParent = + replacement === "repository" ? join(external, "src") : external; + mkdirSync(externalParent, { recursive: true }); + writeFileSync( + join(externalParent, filename), + generator === "ranking" + ? "outside = 'SYNTHETIC_EXTERNAL_MARKER'\n" + : Buffer.from("\0SYNTHETIC_EXTERNAL_MARKER"), + ); + + const runGenerator = (selected: string, destination: string) => + spawnSync( + python!, + [ + "-I", + "-B", + "-c", + probe, + scripts, + repository, + base, + external, + parked, + destination, + selected, + replacement, + ], + { encoding: "utf8" }, + ); + const safeOutput = `${output}.safe`; + const safe = runGenerator(`safe-${generator}`, safeOutput); + expect(safe.status, `${generator}: ${safe.stderr}`).toBe(0); + if (generator === "ranking") { + expect(JSON.parse(readFileSync(safeOutput, "utf8"))).toMatchObject({ + path: `src/${filename}`, + preview: "inside = 2", + }); + } else { + expect(readFileSync(safeOutput, "utf8")).toBe(`src/${filename}\n`); + } + + const result = runGenerator(generator, output); + + expect( + result.status, + `${generator}: ${result.stdout}\n${result.stderr}`, + ).toBe(generator === "ranking" ? 1 : 2); + expect(result.stderr.toLowerCase()).toContain( + "inside the selected target", + ); + + if (generator === "ranking") { + if (replacement === "repository") { + renameSync(repository, external); + renameSync(parked, repository); + } else { + unlinkSync(parent); + renameSync(parked, parent); + } + } + } + }, +); + +test("skips working-tree files that disappear or become unreadable during preview", () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-diff-file-churn-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + const source = join(repository, "src", "handler.py"); + mkdirSync(join(repository, "src"), { recursive: true }); + git(repository, "init", "-q"); + writeFileSync(source, "inside = 1\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const scripts = join(PLUGIN_ROOT, "scripts"); + const probe = [ + "import contextlib, errno, sys, types", + "scripts, repository, base, output, generator, failure = sys.argv[1:]", + "sys.path.insert(0, scripts)", + "import generate_rank_input as ranking", + "from windows_scan_local_files import WindowsScanLocalFileError", + "read_changed_path = ranking.preview_for_changed_path", + "def unavailable_leaf(path, target, preview_bytes, **kwargs):", + " if failure in {'read', 'close'}:", + " fdopen = ranking.os.fdopen", + " @contextlib.contextmanager", + " def faulty_stream(*args, **options):", + " with fdopen(*args, **options) as source:", + " def read(*args):", + " if failure == 'read':", + " raise OSError(errno.EIO, 'synthetic read failure')", + " return source.read(*args)", + " yield types.SimpleNamespace(read=read)", + " if failure == 'close':", + " raise OSError(errno.EIO, 'synthetic close failure')", + " ranking.os.fdopen = faulty_stream", + " return read_changed_path(path, target, preview_bytes, **kwargs)", + " if failure.startswith('windows-'):", + " expected_identity = kwargs['expected_root_identity']", + " code = 22 if failure == 'windows-reparse' else int(failure.rsplit('-', 1)[-1])", + " failed_path = path.parent if 'parent' in failure else path", + " def unavailable_windows_file(*args, **kwargs):", + " assert kwargs['expected_root_identity'] == expected_identity", + " raise WindowsScanLocalFileError(code, 'synthetic Windows file error', str(failed_path))", + " original_backend, original_platform = ranking._windows_scan_local_files, ranking.os.name", + " ranking._windows_scan_local_files = lambda: types.SimpleNamespace(open_read_fd=unavailable_windows_file)", + " ranking.os.name = 'nt'", + " try:", + " return read_changed_path(path, target, preview_bytes, **kwargs)", + " finally:", + " ranking._windows_scan_local_files, ranking.os.name = original_backend, original_platform", + " if failure == 'unreadable':", + " raise PermissionError('synthetic in-target file became unreadable')", + " path.unlink()", + " if failure == 'parent-missing':", + " path.parent.rmdir()", + " return read_changed_path(path, target, preview_bytes, **kwargs)", + "ranking.preview_for_changed_path = unavailable_leaf", + "if generator == 'ranking':", + " sys.argv = ['ranking', 'make-diff-rank-input', '--repo', repository, '--base', base, '--mode', 'local-patch', '--out', output]", + " ranking.main()", + "else:", + " import generate_in_scope_files as inventory", + " sys.argv = ['inventory', '--repo', repository, '--scope', '.', '--out', output, '--diff-base', base, '--diff-mode', 'local-patch']", + " inventory.main()", + ].join("\n"); + + for (const generator of ["ranking", "inventory"]) { + for (const failure of [ + "missing", + "unreadable", + "read", + "close", + "parent-missing", + "windows-missing-2", + "windows-missing-3", + "windows-denied-5", + "windows-denied-13", + "windows-sharing-32", + "windows-lock-33", + "windows-parent-missing-2", + "windows-parent-denied-5", + "windows-parent-sharing-32", + "windows-parent-lock-33", + "windows-reparse", + ]) { + mkdirSync(join(repository, "src"), { recursive: true }); + writeFileSync(source, "inside = 2\n"); + const output = join(root, `${generator}-${failure}.jsonl`); + const result = spawnSync( + python!, + [ + "-I", + "-B", + "-c", + probe, + scripts, + repository, + base, + output, + generator, + failure, + ], + { encoding: "utf8" }, + ); + + if (failure.includes("parent") || failure === "windows-reparse") { + expect( + result.status, + `${generator}/${failure}: ${result.stdout}\n${result.stderr}`, + ).toBe(generator === "ranking" ? 1 : 2); + expect(result.stderr.toLowerCase()).toContain( + "inside the selected target", + ); + } else { + expect( + result.status, + `${generator}/${failure}: ${result.stdout}\n${result.stderr}`, + ).toBe(0); + expect(readFileSync(output, "utf8")).toBe(""); + } + } + } +}); + +test("diff inventory and previews stay inside the selected repository", () => { const root = realpathSync( mkdtempSync(join(tmpdir(), "codex-security-diff-rank-")), ); temporaryRoots.push(root); const repository = join(root, "repository"); const nested = join(repository, "src", "nested"); + const removedParent = join(repository, "removed"); mkdirSync(nested, { recursive: true }); + mkdirSync(removedParent); git(repository, "init", "-q"); writeFileSync(join(repository, "src", "handler.py"), "value = 1\n"); writeFileSync(join(repository, "src", "deleted.py"), "removed = True\n"); writeFileSync(join(repository, "src", "entry.py"), "handler.py"); writeFileSync(join(nested, "linked.py"), "value = 1\n"); + writeFileSync(join(removedParent, "deleted.py"), "removed = True\n"); git(repository, "add", "."); const originalLink = git(repository, "hash-object", "src/entry.py"); git( @@ -72,6 +436,7 @@ test("diff previews stay inside the selected repository", () => { writeFileSync(join(repository, "src", "entry.py"), "nested/linked.py"); writeFileSync(join(nested, "linked.py"), "value = 2\n"); rmSync(join(repository, "src", "deleted.py")); + rmSync(removedParent, { recursive: true }); git(repository, "add", "."); const updatedLink = git(repository, "hash-object", "src/entry.py"); git( @@ -83,41 +448,57 @@ test("diff previews stay inside the selected repository", () => { git(repository, "commit", "-qm", "selected changes"); const head = git(repository, "rev-parse", "HEAD"); - const externalFixture = join(root, "synthetic-fixture"); - mkdirSync(externalFixture); - writeFileSync(join(externalFixture, "linked.py"), "synthetic = True\n"); - rmSync(nested, { recursive: true }); - symlinkSync(externalFixture, nested, "junction"); - const python = pythonExecutable(); expect(python).not.toBeNull(); const output = join(root, "rank-input.jsonl"); - const result = spawnSync( - python!, - [ - "-B", - join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), - "make-diff-rank-input", - "--repo", - repository, - "--base", - base, - "--head", - head, - "--mode", - "local-patch", - "--out", - output, - ], - { encoding: "utf8" }, - ); + const args = [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + base, + "--head", + head, + "--mode", + "local-patch", + "--out", + output, + ]; + const inventoryOutput = join(root, "in-scope-files.txt"); + const inventoryArgs = [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + inventoryOutput, + "--diff-base", + base, + "--diff-head", + head, + "--diff-mode", + "local-patch", + ]; + const result = spawnSync(python!, args, { encoding: "utf8" }); + const safeInventory = spawnSync(python!, inventoryArgs, { + encoding: "utf8", + }); expect(result.status, result.stderr).toBe(0); + expect(safeInventory.status, safeInventory.stderr).toBe(0); + expect(readFileSync(inventoryOutput, "utf8")).toContain( + "removed/deleted.py\n", + ); const rows = readFileSync(output, "utf8") .trim() .split("\n") .map((row) => JSON.parse(row) as { path: string; preview: string }); expect(rows.map((row) => row.path)).toEqual([ + "removed/deleted.py", "src/deleted.py", "src/entry.py", "src/handler.py", @@ -127,7 +508,40 @@ test("diff previews stay inside the selected repository", () => { "value = 2", ); expect(rows.find((row) => row.path === "src/nested/linked.py")?.preview).toBe( - "", + "value = 2", + ); + + const externalFixture = join(root, "synthetic-fixture"); + mkdirSync(externalFixture); + writeFileSync(join(externalFixture, "deleted.py"), "external = True\n"); + writeFileSync(join(externalFixture, "linked.py"), "synthetic = True\n"); + symlinkSync(externalFixture, removedParent, "junction"); + + const escapedDeletion = spawnSync(python!, args, { encoding: "utf8" }); + const deletionInventory = spawnSync(python!, inventoryArgs, { + encoding: "utf8", + }); + expect([escapedDeletion.status, deletionInventory.status]).toEqual([1, 2]); + expect(escapedDeletion.stderr).toContain( + "Changed Git working-tree paths must stay inside the selected target.", + ); + expect(deletionInventory.stderr).toContain( + "changed Git working-tree paths must stay inside the selected target", + ); + + unlinkSync(removedParent); + git(repository, "update-index", "--skip-worktree", "src/nested/linked.py"); + rmSync(nested, { recursive: true }); + symlinkSync(externalFixture, nested, "junction"); + + const escaped = spawnSync(python!, args, { encoding: "utf8" }); + const inventory = spawnSync(python!, inventoryArgs, { encoding: "utf8" }); + expect([escaped.status, inventory.status]).toEqual([1, 2]); + expect(escaped.stderr).toContain( + "Changed Git working-tree paths must stay inside the selected target.", + ); + expect(inventory.stderr).toContain( + "changed Git working-tree paths must stay inside the selected target", ); }); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index c4cbd7023..c32a74d93 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1953,7 +1953,7 @@ describe("plugin runtime preparation", () => { ]); }); - test.each(["0.1.60", "0.1.71", "0.1.75"])( + test.each(["0.1.60", "0.1.72", "0.1.76"])( "upgrades the %s cache and restores with the SDK-owned helper", async (previousVersion) => { const root = await temporaryDirectory(); @@ -2005,6 +2005,8 @@ describe("plugin runtime preparation", () => { for (const script of [ "workbench_target.py", "finalize_scan_contract.py", + "generate_rank_input.py", + "generate_in_scope_files.py", ]) { expect( await readFile(join(upgraded.installedRoot, "scripts", script)),