From 45f56e891a89f2611e1dd2bdbfa91a92a878ff69 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 22:50:33 -0700 Subject: [PATCH 001/106] fix(scan): keep ignored files out of security inventories --- .../scripts/generate_in_scope_files.py | 2 +- .../tests-ts/scan-inventory.test.ts | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 sdk/typescript/tests-ts/scan-inventory.test.ts 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 a2eeb6ca..d4b98a88 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -68,7 +68,7 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: """Atomically write the exact ripgrep inventory sorted as ``LC_ALL=C``.""" - command = ["rg", "--files", "--hidden", "--no-ignore", "--glob", "!.git/**", "--", scope] + command = ["rg", "--files", "--hidden", "--glob", "!.git/**", "--", scope] with tempfile.TemporaryFile(mode="w+b") as inventory: try: result = subprocess.run( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts new file mode 100644 index 00000000..5423d057 --- /dev/null +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -0,0 +1,72 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("security scan file inventory", () => { + test("includes hidden source files without exposing ignored repository files", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-scan-inventory-")), + ); + temporaryDirectories.push(root); + + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(join(repository, "src"), { recursive: true }); + await mkdir(join(repository, "ignored")); + execFileSync("git", ["init", "-q"], { cwd: repository }); + + await Promise.all([ + writeFile(join(repository, ".gitignore"), "ignored/\n.env\n"), + writeFile(join(repository, ".env"), "SECRET=private\n"), + writeFile(join(repository, ".visible-config"), "visible=true\n"), + writeFile(join(repository, "ignored", "secret.ts"), "private data\n"), + writeFile(join(repository, "src", "handler.ts"), "export {};\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trimEnd().split("\n")).toEqual([ + "./.gitignore", + "./.visible-config", + "./src/handler.ts", + ]); + }); +}); From b6677984b71fd083cb898ddd1d83d465bcdd80dc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 22:59:37 -0700 Subject: [PATCH 002/106] fix(scan): preserve ignored files tracked by Git --- .../scripts/generate_in_scope_files.py | 41 ++++++++++++++++++- sdk/typescript/tests-ts/runtime.test.ts | 3 +- .../tests-ts/scan-inventory.test.ts | 20 ++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) 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 d4b98a88..7898cc05 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import os import subprocess import sys import tempfile @@ -67,7 +68,7 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: - """Atomically write the exact ripgrep inventory sorted as ``LC_ALL=C``.""" + """Atomically inventory visible files and ignored files tracked by Git.""" command = ["rg", "--files", "--hidden", "--glob", "!.git/**", "--", scope] with tempfile.TemporaryFile(mode="w+b") as inventory: try: @@ -89,7 +90,43 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: raise InventoryError(message) inventory.seek(0) - rows = sorted(inventory) + rows = set(inventory) + + if (repository / ".git").exists(): + command = [ + "git", + "ls-files", + "--cached", + "--ignored", + "--exclude-standard", + "-z", + "--", + scope, + ] + try: + tracked = subprocess.run( + command, + cwd=repository, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError as error: + raise InventoryError(f"could not list ignored tracked files: {error}") from error + + if tracked.returncode: + detail = tracked.stderr.decode("utf-8", errors="replace").strip() + message = f"git ls-files exited with status {tracked.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + + prefix = b"./" if scope == "." or scope.startswith("./") else b"" + for relative in tracked.stdout.split(b"\0"): + if relative and (repository / os.fsdecode(relative)).is_file(): + rows.add(prefix + relative + b"\n") + + rows = sorted(rows) output.parent.mkdir(parents=True, exist_ok=True) temporary: Path | None = None diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 65a26b24..053be71f 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -229,7 +229,8 @@ describe("plugin runtime preparation", () => { join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), "utf8", ); - expect(generator).toContain('"--no-ignore"'); + expect(generator).not.toContain('"--no-ignore"'); + expect(generator).toContain('"--ignored"'); return; } diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 5423d057..731ed47e 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -24,6 +24,16 @@ afterEach(async () => { describe("security scan file inventory", () => { test("includes hidden source files without exposing ignored repository files", async () => { + if (Bun.which("rg") === null) { + const generator = await readFile( + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "utf8", + ); + expect(generator).not.toContain('"--no-ignore"'); + expect(generator).toContain('"--ignored"'); + return; + } + const root = await realpath( await mkdtemp(join(tmpdir(), "codex-security-scan-inventory-")), ); @@ -36,12 +46,19 @@ describe("security scan file inventory", () => { execFileSync("git", ["init", "-q"], { cwd: repository }); await Promise.all([ - writeFile(join(repository, ".gitignore"), "ignored/\n.env\n"), + writeFile( + join(repository, ".gitignore"), + "ignored/\n.env\ntracked.env\n", + ), writeFile(join(repository, ".env"), "SECRET=private\n"), writeFile(join(repository, ".visible-config"), "visible=true\n"), writeFile(join(repository, "ignored", "secret.ts"), "private data\n"), writeFile(join(repository, "src", "handler.ts"), "export {};\n"), + writeFile(join(repository, "tracked.env"), "checked in intentionally\n"), ]); + execFileSync("git", ["add", "--force", "--", "tracked.env"], { + cwd: repository, + }); const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -67,6 +84,7 @@ describe("security scan file inventory", () => { "./.gitignore", "./.visible-config", "./src/handler.ts", + "./tracked.env", ]); }); }); From 540bbd4802e33fb60f605710d1f46a714a7a3039 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:06:22 -0700 Subject: [PATCH 003/106] fix(scan): confine Git-aware inventory to safe paths --- .../scripts/generate_in_scope_files.py | 24 ++++++- .../tests-ts/scan-inventory.test.ts | 62 ++++++++++++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) 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 7898cc05..98a3009a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -69,7 +69,16 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: """Atomically inventory visible files and ignored files tracked by Git.""" - command = ["rg", "--files", "--hidden", "--glob", "!.git/**", "--", scope] + command = [ + "rg", + "--files", + "--hidden", + "--no-require-git", + "--glob", + "!.git/**", + "--", + scope, + ] with tempfile.TemporaryFile(mode="w+b") as inventory: try: result = subprocess.run( @@ -95,6 +104,7 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: if (repository / ".git").exists(): command = [ "git", + "--literal-pathspecs", "ls-files", "--cached", "--ignored", @@ -123,8 +133,16 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: prefix = b"./" if scope == "." or scope.startswith("./") else b"" for relative in tracked.stdout.split(b"\0"): - if relative and (repository / os.fsdecode(relative)).is_file(): - rows.add(prefix + relative + b"\n") + if not relative: + continue + candidate = repository / os.fsdecode(relative) + if candidate.is_symlink() or not candidate.is_file(): + continue + try: + candidate.resolve(strict=True).relative_to(repository) + except (OSError, ValueError): + continue + rows.add(prefix + relative + b"\n") rows = sorted(rows) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 731ed47e..7a553fbb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -5,6 +5,7 @@ import { readFile, realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -31,6 +32,8 @@ describe("security scan file inventory", () => { ); expect(generator).not.toContain('"--no-ignore"'); expect(generator).toContain('"--ignored"'); + expect(generator).toContain('"--no-require-git"'); + expect(generator).toContain('"--literal-pathspecs"'); return; } @@ -48,7 +51,7 @@ describe("security scan file inventory", () => { await Promise.all([ writeFile( join(repository, ".gitignore"), - "ignored/\n.env\ntracked.env\n", + "ignored/\n.env\ntracked.env\ntracked-link\n", ), writeFile(join(repository, ".env"), "SECRET=private\n"), writeFile(join(repository, ".visible-config"), "visible=true\n"), @@ -59,6 +62,14 @@ describe("security scan file inventory", () => { execFileSync("git", ["add", "--force", "--", "tracked.env"], { cwd: repository, }); + if (process.platform !== "win32") { + const external = join(root, "external.txt"); + await writeFile(external, "private external file\n"); + await symlink(external, join(repository, "tracked-link")); + execFileSync("git", ["add", "--force", "--", "tracked-link"], { + cwd: repository, + }); + } const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -80,11 +91,58 @@ describe("security scan file inventory", () => { { cwd: repository, stdio: "pipe" }, ); - expect((await readFile(output, "utf8")).trimEnd().split("\n")).toEqual([ + expect( + (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")), + ).toEqual([ "./.gitignore", "./.visible-config", "./src/handler.ts", "./tracked.env", ]); }); + + test("respects ignore files in non-Git directory snapshots", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-directory-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "snapshot"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + await Promise.all([ + writeFile(join(repository, ".gitignore"), ".env\n"), + writeFile(join(repository, ".env"), "SECRET=private\n"), + writeFile(join(repository, "source.ts"), "export {};\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + const rows = (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toEqual(["./.gitignore", "./source.ts"]); + }); }); From 519f6c970ac1436282e43bb1f26ee25fe42e699d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:19:44 -0700 Subject: [PATCH 004/106] fix(scan): harden tracked inventory and tool configuration --- .../scripts/generate_in_scope_files.py | 57 ++++++++++++++----- sdk/typescript/tests-ts/runtime.test.ts | 2 +- .../tests-ts/scan-inventory.test.ts | 20 +++++-- 3 files changed, 60 insertions(+), 19 deletions(-) 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 98a3009a..400cfbcc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -71,9 +71,12 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: """Atomically inventory visible files and ignored files tracked by Git.""" command = [ "rg", + "--no-config", "--files", "--hidden", "--no-require-git", + "--no-ignore-parent", + "--no-ignore-global", "--glob", "!.git/**", "--", @@ -101,28 +104,54 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: inventory.seek(0) rows = set(inventory) - if (repository / ".git").exists(): - command = [ - "git", - "--literal-pathspecs", - "ls-files", - "--cached", - "--ignored", - "--exclude-standard", - "-z", - "--", - scope, - ] + environment = os.environ.copy() + for name in ( + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_WORK_TREE", + ): + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + git = ["git", "-c", "core.fsmonitor=false", "--literal-pathspecs"] + try: + worktree = subprocess.run( + [*git, "rev-parse", "--is-inside-work-tree"], + cwd=repository, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + check=False, + ) + except OSError as error: + if (repository / ".git").exists(): + raise InventoryError(f"could not inspect Git worktree: {error}") from error + worktree = None + + if worktree is not None and worktree.returncode not in (0, 128): + detail = worktree.stderr.decode("utf-8", errors="replace").strip() + message = f"git rev-parse exited with status {worktree.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + + if worktree is not None and worktree.returncode == 0 and worktree.stdout.strip() == b"true": try: tracked = subprocess.run( - command, + [*git, "ls-files", "--cached", "-z", "--", scope], cwd=repository, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, check=False, ) except OSError as error: - raise InventoryError(f"could not list ignored tracked files: {error}") from error + raise InventoryError(f"could not list tracked files: {error}") from error if tracked.returncode: detail = tracked.stderr.decode("utf-8", errors="replace").strip() diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 053be71f..f45d49f2 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -230,7 +230,7 @@ describe("plugin runtime preparation", () => { "utf8", ); expect(generator).not.toContain('"--no-ignore"'); - expect(generator).toContain('"--ignored"'); + expect(generator).toContain('"--cached"'); return; } diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 7a553fbb..4fbce1f2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -31,9 +31,12 @@ describe("security scan file inventory", () => { "utf8", ); expect(generator).not.toContain('"--no-ignore"'); - expect(generator).toContain('"--ignored"'); + expect(generator).toContain('"--cached"'); + expect(generator).toContain('"--no-config"'); + expect(generator).toContain('"--no-ignore-parent"'); expect(generator).toContain('"--no-require-git"'); expect(generator).toContain('"--literal-pathspecs"'); + expect(generator).toContain('"core.fsmonitor=false"'); return; } @@ -58,10 +61,16 @@ describe("security scan file inventory", () => { writeFile(join(repository, "ignored", "secret.ts"), "private data\n"), writeFile(join(repository, "src", "handler.ts"), "export {};\n"), writeFile(join(repository, "tracked.env"), "checked in intentionally\n"), + writeFile(join(repository, ".ignore"), "hidden-by-rg.ts\n"), + writeFile(join(repository, "hidden-by-rg.ts"), "tracked source\n"), ]); - execFileSync("git", ["add", "--force", "--", "tracked.env"], { - cwd: repository, - }); + execFileSync( + "git", + ["add", "--force", "--", "tracked.env", "hidden-by-rg.ts"], + { + cwd: repository, + }, + ); if (process.platform !== "win32") { const external = join(root, "external.txt"); await writeFile(external, "private external file\n"); @@ -98,7 +107,9 @@ describe("security scan file inventory", () => { .map((path) => path.replaceAll("\\", "/")), ).toEqual([ "./.gitignore", + "./.ignore", "./.visible-config", + "./hidden-by-rg.ts", "./src/handler.ts", "./tracked.env", ]); @@ -114,6 +125,7 @@ describe("security scan file inventory", () => { const repository = join(root, "snapshot"); const output = join(root, "in-scope-files.txt"); await mkdir(repository); + await writeFile(join(root, ".gitignore"), "snapshot/source.ts\n"); await Promise.all([ writeFile(join(repository, ".gitignore"), ".env\n"), writeFile(join(repository, ".env"), "SECRET=private\n"), From 52b82f9444983d075ba1054443dabe476122aa95 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:22:02 -0700 Subject: [PATCH 005/106] fix(scan): retain repository ignore rules for nested scopes --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 400cfbcc..7f2c9247 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -79,9 +79,12 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: "--no-ignore-global", "--glob", "!.git/**", - "--", - scope, ] + for name in (".gitignore", ".ignore", ".rgignore"): + ignore = repository / name + if ignore.is_file() and not ignore.is_symlink(): + command.extend(["--ignore-file", str(ignore)]) + command.extend(["--", scope]) with tempfile.TemporaryFile(mode="w+b") as inventory: try: result = subprocess.run( From bb7c5ce615472b88191052d39710c28b5d963873 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:35:27 -0700 Subject: [PATCH 006/106] fix(scan): fail closed and honor Git-local exclusions --- .../scripts/generate_in_scope_files.py | 69 ++++++++++++------- .../tests-ts/scan-inventory.test.ts | 8 +++ 2 files changed, 53 insertions(+), 24 deletions(-) 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 7f2c9247..874c1df3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -136,35 +136,52 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: raise InventoryError(f"could not inspect Git worktree: {error}") from error worktree = None - if worktree is not None and worktree.returncode not in (0, 128): + if worktree is not None and worktree.returncode: detail = worktree.stderr.decode("utf-8", errors="replace").strip() - message = f"git rev-parse exited with status {worktree.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) - - if worktree is not None and worktree.returncode == 0 and worktree.stdout.strip() == b"true": - try: - tracked = subprocess.run( - [*git, "ls-files", "--cached", "-z", "--", scope], - cwd=repository, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=environment, - check=False, - ) - except OSError as error: - raise InventoryError(f"could not list tracked files: {error}") from error - - if tracked.returncode: - detail = tracked.stderr.decode("utf-8", errors="replace").strip() - message = f"git ls-files exited with status {tracked.returncode}" + if worktree.returncode == 128 and "not a git repository" in detail.lower(): + worktree = None + else: + message = f"git rev-parse exited with status {worktree.returncode}" if detail: message = f"{message}: {detail}" raise InventoryError(message) + if worktree is not None and worktree.stdout.strip() == b"true": prefix = b"./" if scope == "." or scope.startswith("./") else b"" - for relative in tracked.stdout.split(b"\0"): + listed: list[bytes] = [] + for arguments in (["--cached"], ["--others", "--exclude-standard"]): + try: + result = subprocess.run( + [*git, "ls-files", *arguments, "-z", "--", scope], + cwd=repository, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + check=False, + ) + except OSError as error: + raise InventoryError(f"could not list repository files: {error}") from error + if result.returncode: + detail = result.stderr.decode("utf-8", errors="replace").strip() + message = f"git ls-files exited with status {result.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + listed.append(result.stdout) + + def normalized(path: bytes) -> bytes: + return path.replace(b"\\", b"/") if os.name == "nt" else path + + allowed = { + normalized(prefix + relative) + for collection in listed + for relative in collection.split(b"\0") + if relative + } + rows = {row for row in rows if normalized(row.rstrip(b"\r\n")) in allowed} + recorded = {normalized(row.rstrip(b"\r\n")) for row in rows} + + for relative in listed[0].split(b"\0"): if not relative: continue candidate = repository / os.fsdecode(relative) @@ -174,7 +191,11 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: candidate.resolve(strict=True).relative_to(repository) except (OSError, ValueError): continue - rows.add(prefix + relative + b"\n") + relative_path = prefix + relative + key = normalized(relative_path) + if key not in recorded: + rows.add(relative_path + b"\n") + recorded.add(key) rows = sorted(rows) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4fbce1f2..3522d5d2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -63,7 +63,15 @@ describe("security scan file inventory", () => { writeFile(join(repository, "tracked.env"), "checked in intentionally\n"), writeFile(join(repository, ".ignore"), "hidden-by-rg.ts\n"), writeFile(join(repository, "hidden-by-rg.ts"), "tracked source\n"), + writeFile( + join(repository, "info-secret.ts"), + "local Git-excluded data\n", + ), ]); + await writeFile( + join(repository, ".git", "info", "exclude"), + "info-secret.ts\n", + ); execFileSync( "git", ["add", "--force", "--", "tracked.env", "hidden-by-rg.ts"], From 1baf11d67c03e35e08706501c376051bceeb0957 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 23:54:58 -0700 Subject: [PATCH 007/106] fix(scan): preserve explicit scopes and nested worktree files --- .../scripts/generate_in_scope_files.py | 43 +++++++- .../tests-ts/scan-inventory.test.ts | 100 +++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) 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 874c1df3..3badbbf4 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -121,7 +121,15 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: ): environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" - git = ["git", "-c", "core.fsmonitor=false", "--literal-pathspecs"] + environment["LC_ALL"] = "C" + git = [ + "git", + "-c", + "core.fsmonitor=false", + "-c", + f"core.excludesFile={os.devnull}", + "--literal-pathspecs", + ] try: worktree = subprocess.run( [*git, "rev-parse", "--is-inside-work-tree"], @@ -178,7 +186,38 @@ def normalized(path: bytes) -> bytes: for relative in collection.split(b"\0") if relative } - rows = {row for row in rows if normalized(row.rstrip(b"\r\n")) in allowed} + nested_worktrees = tuple(path for path in allowed if path.endswith(b"/")) + explicitly_ignored = False + if scope not in (".", "./"): + ignored_environment = environment.copy() + ignored_environment.pop("GIT_LITERAL_PATHSPECS", None) + explicit_path = scope if scope.startswith("./") else f"./{scope}" + try: + ignored = subprocess.run( + [*git[:-1], "check-ignore", "--quiet", "--no-index", "--", explicit_path], + cwd=repository, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=ignored_environment, + check=False, + ) + except OSError as error: + raise InventoryError(f"could not inspect scoped Git ignores: {error}") from error + if ignored.returncode not in (0, 1): + detail = ignored.stderr.decode("utf-8", errors="replace").strip() + message = f"git check-ignore exited with status {ignored.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + explicitly_ignored = ignored.returncode == 0 + + if not explicitly_ignored: + rows = { + row + for row in rows + if (path := normalized(row.rstrip(b"\r\n"))) in allowed + or any(path.startswith(worktree) for worktree in nested_worktrees) + } recorded = {normalized(row.rstrip(b"\r\n")) for row in rows} for relative in listed[0].split(b"\0"): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 3522d5d2..ebffd428 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -47,6 +47,7 @@ describe("security scan file inventory", () => { const repository = join(root, "repository"); const output = join(root, "in-scope-files.txt"); + const globalIgnore = join(root, "global-ignore"); await mkdir(join(repository, "src"), { recursive: true }); await mkdir(join(repository, "ignored")); execFileSync("git", ["init", "-q"], { cwd: repository }); @@ -67,6 +68,7 @@ describe("security scan file inventory", () => { join(repository, "info-secret.ts"), "local Git-excluded data\n", ), + writeFile(globalIgnore, "*.ts\n"), ]); await writeFile( join(repository, ".git", "info", "exclude"), @@ -105,7 +107,16 @@ describe("security scan file inventory", () => { "--out", output, ], - { cwd: repository, stdio: "pipe" }, + { + cwd: repository, + stdio: "pipe", + env: { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.excludesFile", + GIT_CONFIG_VALUE_0: globalIgnore, + }, + }, ); expect( @@ -165,4 +176,91 @@ describe("security scan file inventory", () => { .map((path) => path.replaceAll("\\", "/")); expect(rows).toEqual(["./.gitignore", "./source.ts"]); }); + + test("retains visible files inside nested Git worktrees", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-nested-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const nested = join(repository, "nested"); + const output = join(root, "in-scope-files.txt"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: repository }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, ".gitignore"), ".env\n"), + writeFile(join(nested, ".env"), "SECRET=private\n"), + writeFile(join(nested, "tracked.py"), "print('tracked')\n"), + writeFile(join(nested, "local.py"), "print('local')\n"), + ]); + execFileSync("git", ["add", "--", "tracked.py"], { cwd: nested }); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + const rows = (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toContain("./nested/tracked.py"); + expect(rows).toContain("./nested/local.py"); + expect(rows).not.toContain("./nested/.env"); + }); + + test("retains an explicitly scoped Git-ignored file", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-explicit-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: repository }); + await Promise.all([ + writeFile(join(repository, ".gitignore"), "*.skip\n"), + writeFile(join(repository, "selected.skip"), "explicit source\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "selected.skip", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe("selected.skip"); + }); }); From 2de2ba0e3d118523cb340fd93494ec6a1d95be61 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:33:21 -0700 Subject: [PATCH 008/106] Respect scoped ignore ancestry and recursive nested worktrees --- .../scripts/generate_in_scope_files.py | 130 +++++++++++++----- .../tests-ts/scan-inventory.test.ts | 114 +++++++++++++++ 2 files changed, 210 insertions(+), 34 deletions(-) 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 330cedf8..05ba3f90 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -81,11 +81,28 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: """Atomically inventory visible files and ignored files tracked by Git.""" - for directory, children, names in os.walk(repository, followlinks=False): - children[:] = [name for name in children if name != ".git"] - if any((Path(directory) / name).is_symlink() for name in names if name in IGNORE_FILE_NAMES): + selected = (repository / scope).resolve(strict=True) + selected_directory = selected if selected.is_dir() else selected.parent + ancestors: list[Path] = [] + current = selected_directory + while True: + ancestors.append(current) + if current == repository: + break + current = current.parent + ancestors.reverse() + + def reject_symbolic_ignore(directory: Path) -> None: + if any((directory / name).is_symlink() for name in IGNORE_FILE_NAMES): raise InventoryError("symbolic ignore files are not supported") + for ancestor in ancestors: + reject_symbolic_ignore(ancestor) + if selected.is_dir(): + for directory, children, _ in os.walk(selected, followlinks=False): + children[:] = [name for name in children if name != ".git"] + reject_symbolic_ignore(Path(directory)) + command = [ "rg", "--no-config", @@ -97,33 +114,55 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: "--glob", "!.git/**", ] - for name in IGNORE_FILE_NAMES: - ignore = repository / name - if ignore.is_file() and not ignore.is_symlink(): - command.extend(["--ignore-file", str(ignore)]) - command.extend(["--", scope]) - with tempfile.TemporaryFile(mode="w+b") as inventory: - try: - result = subprocess.run( - command, - cwd=repository, - stdout=inventory, - stderr=subprocess.PIPE, - check=False, - timeout=INVENTORY_TIMEOUT_SECONDS, - ) - except (OSError, subprocess.TimeoutExpired) as error: - raise InventoryError(f"could not run ripgrep: {error}") from error - if result.returncode not in (0, 1): - detail = result.stderr.decode("utf-8", errors="replace").strip() - message = f"ripgrep exited with status {result.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) + def ripgrep_inventory(directory: Path, requested_scope: str) -> set[bytes]: + arguments = command.copy() + for name in IGNORE_FILE_NAMES: + ignore = directory / name + if ignore.is_file() and not ignore.is_symlink(): + arguments.extend(["--ignore-file", str(ignore)]) + arguments.extend(["--", requested_scope]) + with tempfile.TemporaryFile(mode="w+b") as inventory: + try: + result = subprocess.run( + arguments, + cwd=directory, + stdout=inventory, + stderr=subprocess.PIPE, + check=False, + timeout=INVENTORY_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise InventoryError(f"could not run ripgrep: {error}") from error + + if result.returncode not in (0, 1): + detail = result.stderr.decode("utf-8", errors="replace").strip() + message = f"ripgrep exited with status {result.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) - inventory.seek(0) - rows = set(inventory) + inventory.seek(0) + return set(inventory) + + def normalized(path: bytes) -> bytes: + return path.replace(b"\\", b"/") if os.name == "nt" else path + + rows = ripgrep_inventory(repository, scope) + for ancestor in ancestors[1:]: + if not any((ancestor / name).is_file() for name in IGNORE_FILE_NAMES): + continue + ancestor_scope = selected.relative_to(ancestor).as_posix() or "." + ancestor_prefix = os.fsencode(ancestor.relative_to(repository).as_posix()) + b"/" + visible = { + normalized(ancestor_prefix + row.removesuffix(b"\n").removeprefix(b"./")) + for row in ripgrep_inventory(ancestor, ancestor_scope) + } + rows = { + row + for row in rows + if normalized(row.removesuffix(b"\n").removeprefix(b"./")) in visible + } environment = os.environ.copy() for name in ( @@ -205,7 +244,6 @@ def run_git( raise InventoryError(message) listed.append(result.stdout) - selected = (repository / scope).resolve(strict=True) nested_roots: set[Path] = set() current = selected if selected.is_dir() else selected.parent while current != repository: @@ -220,7 +258,13 @@ def run_git( if candidate.is_dir() and (candidate / ".git").exists(): nested_roots.add(candidate.resolve(strict=True)) - for nested in sorted(nested_roots): + pending_roots = sorted(nested_roots) + inspected_roots: set[Path] = set() + while pending_roots: + nested = pending_roots.pop(0) + if nested in inspected_roots: + continue + inspected_roots.add(nested) try: nested_scope = selected.relative_to(nested).as_posix() or "." except ValueError: @@ -243,9 +287,21 @@ def run_git( for relative in result.stdout.split(b"\0") if relative ) - - def normalized(path: bytes) -> bytes: - return path.replace(b"\\", b"/") if os.name == "nt" else path + for relative in result.stdout.split(b"\0"): + if not relative: + continue + candidate = nested / os.fsdecode(relative) + if candidate.is_symlink() or not candidate.is_dir(): + continue + if not (candidate / ".git").exists(): + continue + try: + discovered = candidate.resolve(strict=True) + discovered.relative_to(repository) + except (OSError, ValueError): + continue + if discovered not in inspected_roots: + pending_roots.append(discovered) allowed = { normalized(prefix + relative) @@ -256,9 +312,15 @@ def normalized(path: bytes) -> bytes: nested_worktrees = tuple(path for path in allowed if path.endswith(b"/")) explicitly_ignored = False if scope not in (".", "./"): - explicit_path = scope if scope.startswith("./") else f"./{scope}" + enclosing = max( + (root for root in (repository, *inspected_roots) if selected.is_relative_to(root)), + key=lambda root: len(root.parts), + ) + explicit_relative = selected.relative_to(enclosing).as_posix() + explicit_path = f"./{explicit_relative}" ignored = run_git( ["check-ignore", "--quiet", "--no-index", "--", explicit_path], + directory=enclosing, literal=False, ) if ignored.returncode not in (0, 1): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4a5dafd0..ed47c7f9 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -197,6 +197,55 @@ describe("security scan file inventory", () => { expect(rows).toEqual(["./.gitignore", "./source.ts"]); }); + test.each([false, true])( + "applies intermediate scope ignore files (Git repository: %s)", + async (useGit) => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-ancestor-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const scoped = join(repository, "parent", "nested"); + const output = join(root, "in-scope-files.txt"); + await mkdir(scoped, { recursive: true }); + if (useGit) execFileSync("git", ["init", "-q"], { cwd: repository }); + await Promise.all([ + writeFile(join(repository, "parent", ".ignore"), "nested/secret.py\n"), + writeFile( + join(repository, "parent", ".gitignore"), + "nested/private.py\n", + ), + writeFile(join(scoped, "secret.py"), "secret\n"), + writeFile(join(scoped, "private.py"), "private\n"), + writeFile(join(scoped, "safe.py"), "safe\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "parent/nested", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe( + "parent/nested/safe.py", + ); + }, + ); + test("retains visible files inside nested Git worktrees", async () => { if (Bun.which("rg") === null) return; @@ -215,6 +264,8 @@ describe("security scan file inventory", () => { writeFile(join(nested, ".env"), "SECRET=private\n"), writeFile(join(nested, "tracked.py"), "print('tracked')\n"), writeFile(join(nested, "local.py"), "print('local')\n"), + writeFile(join(nested, "chosen.skip"), "explicit nested source\n"), + writeFile(join(nested, ".git", "info", "exclude"), "chosen.skip\n"), ]); execFileSync("git", ["add", "--", "tracked.py"], { cwd: nested }); @@ -261,6 +312,22 @@ describe("security scan file inventory", () => { ); expect((await readFile(output, "utf8")).trim()).toBe("nested/tracked.py"); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "nested/chosen.skip", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).trim()).toBe("nested/chosen.skip"); + execFileSync( "git", [ @@ -274,6 +341,41 @@ describe("security scan file inventory", () => { ], { cwd: nested }, ); + const inner = join(nested, "inner"); + await mkdir(inner); + execFileSync("git", ["init", "-q"], { cwd: inner }); + await writeFile(join(inner, "security.py"), "print('nested security')\n"); + execFileSync("git", ["add", "--", "security.py"], { cwd: inner }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track inner security source", + ], + { cwd: inner }, + ); + execFileSync("git", ["add", "--", "inner"], { + cwd: nested, + stdio: "ignore", + }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track inner worktree", + ], + { cwd: nested }, + ); await writeFile(join(repository, ".gitignore"), "nested/\n"); execFileSync("git", ["add", "--force", "--", "nested"], { cwd: repository, @@ -296,6 +398,9 @@ describe("security scan file inventory", () => { expect((await readFile(output, "utf8")).split("\n")).toContain( "./nested/tracked.py", ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + "./nested/inner/security.py", + ); }); test("retains an explicitly scoped Git-ignored file", async () => { @@ -349,6 +454,9 @@ describe("security scan file inventory", () => { await writeFile(join(repository, "source", "file.ts"), "export {};\n"); await writeFile(join(repository, ".gitignore"), "ignored.ts\n"); await symlink("source", join(repository, "alias")); + const unrelated = join(repository, "unrelated"); + await mkdir(unrelated); + await symlink(join(repository, ".gitignore"), join(unrelated, ".ignore")); const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); @@ -361,6 +469,12 @@ describe("security scan file inventory", () => { output, ]; + execFileSync(python, [...command, "--scope", "source"], { + cwd: repository, + stdio: "pipe", + }); + expect((await readFile(output, "utf8")).trim()).toBe("source/file.ts"); + expect(() => execFileSync(python, [...command, "--scope", "alias"], { cwd: repository, From 7a41f483d537f19214319812fb556b79a96294b1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:47:12 -0700 Subject: [PATCH 009/106] Handle embedded repositories and literal worktree paths --- .../scripts/generate_in_scope_files.py | 25 ++++- .../tests-ts/scan-inventory.test.ts | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) 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 05ba3f90..49056f83 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -96,12 +96,16 @@ def reject_symbolic_ignore(directory: Path) -> None: if any((directory / name).is_symlink() for name in IGNORE_FILE_NAMES): raise InventoryError("symbolic ignore files are not supported") + discovered_roots: set[Path] = set() for ancestor in ancestors: reject_symbolic_ignore(ancestor) if selected.is_dir(): for directory, children, _ in os.walk(selected, followlinks=False): + directory_path = Path(directory) + if directory_path != repository and (directory_path / ".git").exists(): + discovered_roots.add(directory_path) children[:] = [name for name in children if name != ".git"] - reject_symbolic_ignore(Path(directory)) + reject_symbolic_ignore(directory_path) command = [ "rg", @@ -225,7 +229,10 @@ def run_git( if worktree is not None: try: - worktree_root = Path(os.fsdecode(worktree.stdout.strip())).resolve(strict=True) + root_path = worktree.stdout.removesuffix(b"\n") + if os.name == "nt": + root_path = root_path.removesuffix(b"\r") + worktree_root = Path(os.fsdecode(root_path)).resolve(strict=True) except (OSError, ValueError) as error: raise InventoryError(f"could not resolve Git worktree root: {error}") from error if worktree_root != repository: @@ -244,7 +251,7 @@ def run_git( raise InventoryError(message) listed.append(result.stdout) - nested_roots: set[Path] = set() + nested_roots = discovered_roots.copy() current = selected if selected.is_dir() else selected.parent while current != repository: if (current / ".git").exists(): @@ -255,8 +262,16 @@ def run_git( if not relative: continue candidate = repository / os.fsdecode(relative) - if candidate.is_dir() and (candidate / ".git").exists(): - nested_roots.add(candidate.resolve(strict=True)) + if candidate.is_symlink() or not candidate.is_dir(): + continue + if not (candidate / ".git").exists(): + continue + try: + discovered = candidate.resolve(strict=True) + discovered.relative_to(repository) + except (OSError, ValueError): + continue + nested_roots.add(discovered) pending_roots = sorted(nested_roots) inspected_roots: set[Path] = set() diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ed47c7f9..3c714ecb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -89,6 +89,16 @@ describe("security scan file inventory", () => { execFileSync("git", ["add", "--force", "--", "tracked-link"], { cwd: repository, }); + const externalRepository = join(root, "external-repository"); + await mkdir(externalRepository); + execFileSync("git", ["init", "-q"], { cwd: externalRepository }); + await symlink( + externalRepository, + join(repository, "tracked-repository-link"), + ); + execFileSync("git", ["add", "--", "tracked-repository-link"], { + cwd: repository, + }); } const python = @@ -153,6 +163,46 @@ describe("security scan file inventory", () => { expect((await readFile(output, "utf8")).trim()).toBe("src/handler.ts"); }); + test.each(["repository ", "repository\t"])( + "preserves trailing whitespace in the Git worktree root %j", + async (directory) => { + if (process.platform === "win32" || Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-whitespace-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, directory); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: repository }); + await writeFile(join(repository, ".ignore"), "tracked.py\n"); + await writeFile(join(repository, "tracked.py"), "print('tracked')\n"); + execFileSync("git", ["add", "--", "tracked.py"], { cwd: repository }); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + "./tracked.py", + ); + }, + ); + test("respects ignore files in non-Git directory snapshots", async () => { if (Bun.which("rg") === null) return; @@ -403,6 +453,53 @@ describe("security scan file inventory", () => { ); }); + test("discovers embedded repositories beneath outer tracked directories", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-embedded-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const embedded = join(repository, "shared"); + const output = join(root, "in-scope-files.txt"); + await mkdir(embedded, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: repository }); + await writeFile(join(embedded, "outer.py"), "print('outer')\n"); + execFileSync("git", ["add", "--", "shared/outer.py"], { + cwd: repository, + }); + + execFileSync("git", ["init", "-q"], { cwd: embedded }); + await writeFile(join(embedded, ".ignore"), "hidden.py\n"); + await writeFile(join(embedded, "hidden.py"), "print('tracked')\n"); + execFileSync("git", ["add", "--", "hidden.py"], { cwd: embedded }); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + const rows = (await readFile(output, "utf8")) + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toContain("./shared/outer.py"); + expect(rows).toContain("./shared/hidden.py"); + }); + test("retains an explicitly scoped Git-ignored file", async () => { if (Bun.which("rg") === null) return; From 67f26a74009c12aa70cc7126c2c60eb45af60e4e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 11:52:31 -0700 Subject: [PATCH 010/106] Ignore stale embedded Git worktree metadata --- .../scripts/generate_in_scope_files.py | 21 +++++++++++++++---- .../tests-ts/scan-inventory.test.ts | 9 ++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) 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 49056f83..7c322da5 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -215,6 +215,12 @@ def run_git( except (OSError, subprocess.TimeoutExpired) as error: raise InventoryError(f"could not run Git: {error}") from error + def resolve_git_root(value: bytes) -> Path: + root_path = value.removesuffix(b"\n") + if os.name == "nt": + root_path = root_path.removesuffix(b"\r") + return Path(os.fsdecode(root_path)).resolve(strict=True) + worktree = ( run_git(["rev-parse", "--show-toplevel"]) if (repository / ".git").exists() @@ -229,10 +235,7 @@ def run_git( if worktree is not None: try: - root_path = worktree.stdout.removesuffix(b"\n") - if os.name == "nt": - root_path = root_path.removesuffix(b"\r") - worktree_root = Path(os.fsdecode(root_path)).resolve(strict=True) + worktree_root = resolve_git_root(worktree.stdout) except (OSError, ValueError) as error: raise InventoryError(f"could not resolve Git worktree root: {error}") from error if worktree_root != repository: @@ -279,6 +282,16 @@ def run_git( nested = pending_roots.pop(0) if nested in inspected_roots: continue + nested_worktree = run_git( + ["rev-parse", "--show-toplevel"], directory=nested + ) + if nested_worktree.returncode: + continue + try: + if resolve_git_root(nested_worktree.stdout) != nested: + continue + except (OSError, ValueError): + continue inspected_roots.add(nested) try: nested_scope = selected.relative_to(nested).as_posix() or "." diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 3c714ecb..d5a8d17b 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -475,6 +475,14 @@ describe("security scan file inventory", () => { await writeFile(join(embedded, "hidden.py"), "print('tracked')\n"); execFileSync("git", ["add", "--", "hidden.py"], { cwd: embedded }); + const stale = join(repository, "vendor"); + await mkdir(stale); + await writeFile( + join(stale, ".git"), + `gitdir: ${join(root, "missing-git-metadata")}\n`, + ); + await writeFile(join(stale, "source.py"), "print('copied source')\n"); + const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); @@ -498,6 +506,7 @@ describe("security scan file inventory", () => { .map((path) => path.replaceAll("\\", "/")); expect(rows).toContain("./shared/outer.py"); expect(rows).toContain("./shared/hidden.py"); + expect(rows).toContain("./vendor/source.py"); }); test("retains an explicitly scoped Git-ignored file", async () => { From fb6793cf7948c9299e7649fe7c54b5c14f1961c3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:21:22 -0700 Subject: [PATCH 011/106] fix(scan): preserve nested Git inventory boundaries --- .../scripts/generate_in_scope_files.py | 74 +++++++++++++------ .../tests-ts/scan-inventory.test.ts | 59 +++++++++------ 2 files changed, 88 insertions(+), 45 deletions(-) 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 7c322da5..1aa86734 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -10,8 +10,6 @@ import tempfile from pathlib import Path -GIT_TIMEOUT_SECONDS = 10 -INVENTORY_TIMEOUT_SECONDS = 120 IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") @@ -58,9 +56,8 @@ def resolve_scope(repository: Path, value: str) -> str: if not resolved.is_dir() and not resolved.is_file(): raise InventoryError(f"--scope: expected a file or directory: {value}") - if requested.is_absolute(): - return relative.as_posix() if relative.parts else "." - return value + canonical = relative.as_posix() if relative.parts else "." + return f"./{canonical}" if value.startswith("./") and canonical != "." else canonical def resolve_output(value: str) -> Path: @@ -134,9 +131,8 @@ def ripgrep_inventory(directory: Path, requested_scope: str) -> set[bytes]: stdout=inventory, stderr=subprocess.PIPE, check=False, - timeout=INVENTORY_TIMEOUT_SECONDS, ) - except (OSError, subprocess.TimeoutExpired) as error: + except OSError as error: raise InventoryError(f"could not run ripgrep: {error}") from error if result.returncode not in (0, 1): @@ -210,9 +206,8 @@ def run_git( stderr=subprocess.PIPE, env=git_environment, check=False, - timeout=GIT_TIMEOUT_SECONDS, ) - except (OSError, subprocess.TimeoutExpired) as error: + except OSError as error: raise InventoryError(f"could not run Git: {error}") from error def resolve_git_root(value: bytes) -> Path: @@ -241,18 +236,21 @@ def resolve_git_root(value: bytes) -> Path: if worktree_root != repository: worktree = None - if worktree is not None: + if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" - listed: list[bytes] = [] - for arguments in (["--cached"], ["--others", "--exclude-standard"]): - result = run_git(["ls-files", *arguments, "-z", "--", scope]) - if result.returncode: - detail = result.stderr.decode("utf-8", errors="replace").strip() - message = f"git ls-files exited with status {result.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) - listed.append(result.stdout) + listed = [b"", b""] + if worktree is not None: + for index, arguments in enumerate( + (["--cached"], ["--others", "--exclude-standard"]) + ): + result = run_git(["ls-files", *arguments, "-z", "--", scope]) + if result.returncode: + detail = result.stderr.decode("utf-8", errors="replace").strip() + message = f"git ls-files exited with status {result.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + listed[index] = result.stdout nested_roots = discovered_roots.copy() current = selected if selected.is_dir() else selected.parent @@ -286,7 +284,18 @@ def resolve_git_root(value: bytes) -> Path: ["rev-parse", "--show-toplevel"], directory=nested ) if nested_worktree.returncode: - continue + detail = nested_worktree.stderr.decode("utf-8", errors="replace").strip() + if any( + reason in detail.lower() + for reason in ( + "not a git repository", + "gitfile does not point to a valid repository", + ) + ): + continue + raise InventoryError( + f"nested git rev-parse exited with status {nested_worktree.returncode}: {detail}" + ) try: if resolve_git_root(nested_worktree.stdout) != nested: continue @@ -337,11 +346,24 @@ def resolve_git_root(value: bytes) -> Path: for relative in collection.split(b"\0") if relative } - nested_worktrees = tuple(path for path in allowed if path.endswith(b"/")) + inspected_prefixes = tuple( + normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") + for root in inspected_roots + ) + nested_worktrees = tuple( + path + for path in allowed + if path.endswith(b"/") and path not in inspected_prefixes + ) explicitly_ignored = False - if scope not in (".", "./"): + enclosing_roots = inspected_roots.copy() + if worktree is not None: + enclosing_roots.add(repository) + if scope not in (".", "./") and any( + selected.is_relative_to(root) for root in enclosing_roots + ): enclosing = max( - (root for root in (repository, *inspected_roots) if selected.is_relative_to(root)), + (root for root in enclosing_roots if selected.is_relative_to(root)), key=lambda root: len(root.parts), ) explicit_relative = selected.relative_to(enclosing).as_posix() @@ -364,6 +386,10 @@ def resolve_git_root(value: bytes) -> Path: row for row in rows if (path := normalized(row.removesuffix(b"\n"))) in allowed + or ( + worktree is None + and not any(path.startswith(root) for root in inspected_prefixes) + ) or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d5a8d17b..1a536e8c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -211,15 +211,20 @@ describe("security scan file inventory", () => { ); temporaryDirectories.push(root); const repository = join(root, "snapshot"); + const nested = join(repository, "nested"); const output = join(root, "in-scope-files.txt"); - await mkdir(repository); + await mkdir(nested, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: root }); + execFileSync("git", ["init", "-q"], { cwd: nested }); await writeFile(join(root, ".gitignore"), "snapshot/source.ts\n"); await Promise.all([ writeFile(join(repository, ".gitignore"), ".env\n"), writeFile(join(repository, ".env"), "SECRET=private\n"), writeFile(join(repository, "source.ts"), "export {};\n"), + writeFile(join(nested, ".ignore"), "tracked.py\n"), + writeFile(join(nested, "tracked.py"), "print('tracked')\n"), ]); + execFileSync("git", ["add", "--", "tracked.py"], { cwd: nested }); const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); @@ -244,7 +249,12 @@ describe("security scan file inventory", () => { .trimEnd() .split("\n") .map((path) => path.replaceAll("\\", "/")); - expect(rows).toEqual(["./.gitignore", "./source.ts"]); + expect(rows).toEqual([ + "./.gitignore", + "./nested/.ignore", + "./nested/tracked.py", + "./source.ts", + ]); }); test.each([false, true])( @@ -275,24 +285,26 @@ describe("security scan file inventory", () => { const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); - execFileSync( - python, - [ - "-B", - join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), - "--repo", - repository, - "--scope", - "parent/nested", - "--out", - output, - ], - { cwd: repository, stdio: "pipe" }, - ); - - expect((await readFile(output, "utf8")).trim()).toBe( - "parent/nested/safe.py", - ); + for (const scope of ["parent/nested", "parent/./nested"]) { + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + scope, + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe( + "parent/nested/safe.py", + ); + } }, ); @@ -309,9 +321,13 @@ describe("security scan file inventory", () => { await mkdir(nested, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: repository }); execFileSync("git", ["init", "-q"], { cwd: nested }); + execFileSync("git", ["config", "core.ignoreCase", "true"], { + cwd: nested, + }); await Promise.all([ - writeFile(join(nested, ".gitignore"), ".env\n"), + writeFile(join(nested, ".gitignore"), ".env\nsecret.py\n"), writeFile(join(nested, ".env"), "SECRET=private\n"), + writeFile(join(nested, "SECRET.PY"), "private data\n"), writeFile(join(nested, "tracked.py"), "print('tracked')\n"), writeFile(join(nested, "local.py"), "print('local')\n"), writeFile(join(nested, "chosen.skip"), "explicit nested source\n"), @@ -345,6 +361,7 @@ describe("security scan file inventory", () => { expect(rows).toContain("./nested/tracked.py"); expect(rows).toContain("./nested/local.py"); expect(rows).not.toContain("./nested/.env"); + expect(rows).not.toContain("./nested/SECRET.PY"); execFileSync( python, From 0da252a4f331d824073f9a754ac38ee5c0cd4955 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:40:44 -0700 Subject: [PATCH 012/106] Filter ignored directory descendants and avoid repeated inventory copies --- .../scripts/generate_in_scope_files.py | 37 ++++++++------- .../tests-ts/scan-inventory.test.ts | 46 +++++++++++++------ 2 files changed, 51 insertions(+), 32 deletions(-) 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 1aa86734..781f778f 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -8,6 +8,7 @@ import subprocess import sys import tempfile +from collections.abc import Iterator from pathlib import Path IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") @@ -238,7 +239,12 @@ def resolve_git_root(value: bytes) -> Path: if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" - listed = [b"", b""] + listed: list[list[bytes]] = [[], []] + + def listed_paths(index: int) -> Iterator[bytes]: + for chunk in listed[index]: + yield from (relative for relative in chunk.split(b"\0") if relative) + if worktree is not None: for index, arguments in enumerate( (["--cached"], ["--others", "--exclude-standard"]) @@ -250,7 +256,7 @@ def resolve_git_root(value: bytes) -> Path: if detail: message = f"{message}: {detail}" raise InventoryError(message) - listed[index] = result.stdout + listed[index].append(result.stdout) nested_roots = discovered_roots.copy() current = selected if selected.is_dir() else selected.parent @@ -258,10 +264,8 @@ def resolve_git_root(value: bytes) -> Path: if (current / ".git").exists(): nested_roots.add(current) current = current.parent - for collection in listed: - for relative in collection.split(b"\0"): - if not relative: - continue + for index in range(len(listed)): + for relative in listed_paths(index): candidate = repository / os.fsdecode(relative) if candidate.is_symlink() or not candidate.is_dir(): continue @@ -319,10 +323,12 @@ def resolve_git_root(value: bytes) -> Path: raise InventoryError( f"nested git ls-files exited with status {result.returncode}: {detail}" ) - listed[index] += b"".join( - nested_prefix + relative + b"\0" - for relative in result.stdout.split(b"\0") - if relative + listed[index].append( + b"".join( + nested_prefix + relative + b"\0" + for relative in result.stdout.split(b"\0") + if relative + ) ) for relative in result.stdout.split(b"\0"): if not relative: @@ -342,9 +348,8 @@ def resolve_git_root(value: bytes) -> Path: allowed = { normalized(prefix + relative) - for collection in listed - for relative in collection.split(b"\0") - if relative + for index in range(len(listed)) + for relative in listed_paths(index) } inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") @@ -379,7 +384,7 @@ def resolve_git_root(value: bytes) -> Path: if detail: message = f"{message}: {detail}" raise InventoryError(message) - explicitly_ignored = ignored.returncode == 0 + explicitly_ignored = ignored.returncode == 0 and selected.is_file() if not explicitly_ignored: rows = { @@ -394,9 +399,7 @@ def resolve_git_root(value: bytes) -> Path: } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} - for relative in listed[0].split(b"\0"): - if not relative: - continue + for relative in listed_paths(0): candidate = repository / os.fsdecode(relative) if candidate.is_symlink() or not candidate.is_file(): continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 1a536e8c..59397f7a 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -526,7 +526,7 @@ describe("security scan file inventory", () => { expect(rows).toContain("./vendor/source.py"); }); - test("retains an explicitly scoped Git-ignored file", async () => { + test("retains ignored explicit files without exposing ignored directory descendants", async () => { if (Bun.which("rg") === null) return; const root = await realpath( @@ -546,22 +546,38 @@ describe("security scan file inventory", () => { Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); expect(python).not.toBeNull(); if (python === null) throw new Error("A Python interpreter is required."); - execFileSync( - python, - [ - "-B", - join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), - "--repo", - repository, - "--scope", - "selected.skip", - "--out", - output, - ], - { cwd: repository, stdio: "pipe" }, - ); + const enumerate = (scope: string) => + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + scope, + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + enumerate("selected.skip"); expect((await readFile(output, "utf8")).trim()).toBe("selected.skip"); + + const ignored = join(repository, "ignored"); + await mkdir(ignored); + await Promise.all([ + writeFile(join(repository, ".gitignore"), "*.skip\nignored/\n"), + writeFile(join(ignored, "public.py"), "tracked source\n"), + writeFile(join(ignored, "private.py"), "ignored source\n"), + ]); + execFileSync("git", ["add", "--force", "ignored/public.py"], { + cwd: repository, + }); + + enumerate("ignored"); + expect((await readFile(output, "utf8")).trim()).toBe("ignored/public.py"); }); test("rejects symbolic scope and ignore-file paths", async () => { From a425ac4b4b21f8c6ddf88a290518f64d9f5d3f9b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 14:56:31 -0700 Subject: [PATCH 013/106] Keep ignored scoped directories private across repository layouts --- .../scripts/generate_in_scope_files.py | 15 ++++++++++-- .../tests-ts/scan-inventory.test.ts | 24 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) 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 781f778f..a54327f9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -5,6 +5,7 @@ import argparse import os +import re import subprocess import sys import tempfile @@ -117,13 +118,19 @@ def reject_symbolic_ignore(directory: Path) -> None: "!.git/**", ] - def ripgrep_inventory(directory: Path, requested_scope: str) -> set[bytes]: + def ripgrep_inventory( + directory: Path, requested_scope: str, *, directory_guard: bool = False + ) -> set[bytes]: arguments = command.copy() for name in IGNORE_FILE_NAMES: ignore = directory / name if ignore.is_file() and not ignore.is_symlink(): arguments.extend(["--ignore-file", str(ignore)]) - arguments.extend(["--", requested_scope]) + if directory_guard: + relative_scope = requested_scope.removeprefix("./") + arguments.extend(["--glob", f"/{re.escape(relative_scope)}/**", "--", "."]) + else: + arguments.extend(["--", requested_scope]) with tempfile.TemporaryFile(mode="w+b") as inventory: try: result = subprocess.run( @@ -150,6 +157,10 @@ def normalized(path: bytes) -> bytes: return path.replace(b"\\", b"/") if os.name == "nt" else path rows = ripgrep_inventory(repository, scope) + if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( + repository, scope, directory_guard=True + ): + rows.clear() for ancestor in ancestors[1:]: if not any((ancestor / name).is_file() for name in IGNORE_FILE_NAMES): continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 59397f7a..89a5b9d6 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -546,20 +546,20 @@ describe("security scan file inventory", () => { Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); expect(python).not.toBeNull(); if (python === null) throw new Error("A Python interpreter is required."); - const enumerate = (scope: string) => + const enumerate = (scope: string, target = repository) => execFileSync( python, [ "-B", join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), "--repo", - repository, + target, "--scope", scope, "--out", output, ], - { cwd: repository, stdio: "pipe" }, + { cwd: target, stdio: "pipe" }, ); enumerate("selected.skip"); @@ -578,6 +578,24 @@ describe("security scan file inventory", () => { enumerate("ignored"); expect((await readFile(output, "utf8")).trim()).toBe("ignored/public.py"); + + const snapshot = join(root, "snapshot"); + const nested = join(snapshot, "ignored"); + await mkdir(nested, { recursive: true }); + await Promise.all([ + writeFile(join(snapshot, ".gitignore"), "ignored/\n"), + writeFile(join(nested, "private.py"), "ignored source\n"), + ]); + + enumerate("ignored", snapshot); + expect((await readFile(output, "utf8")).trim()).toBe(""); + + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, "public.py"), "tracked nested source\n"); + execFileSync("git", ["add", "public.py"], { cwd: nested }); + + enumerate("ignored", snapshot); + expect((await readFile(output, "utf8")).trim()).toBe("ignored/public.py"); }); test("rejects symbolic scope and ignore-file paths", async () => { From f1b52740a4d5d3f1048ed66137f696382a3f8669 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:16:38 -0700 Subject: [PATCH 014/106] Preserve literal scoped paths and short-circuit ignored directory checks --- .../scripts/generate_in_scope_files.py | 12 +++++++--- sdk/typescript/src/targets.ts | 23 +++++++++++++++++++ .../tests-ts/scan-inventory.test.ts | 8 +++++-- sdk/typescript/tests-ts/targets.test.ts | 14 +++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) 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 a54327f9..29a3c6b5 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -128,7 +128,9 @@ def ripgrep_inventory( arguments.extend(["--ignore-file", str(ignore)]) if directory_guard: relative_scope = requested_scope.removeprefix("./") - arguments.extend(["--glob", f"/{re.escape(relative_scope)}/**", "--", "."]) + arguments.extend( + ["--quiet", "--glob", f"/{re.escape(relative_scope)}/**", "--", "."] + ) else: arguments.extend(["--", requested_scope]) with tempfile.TemporaryFile(mode="w+b") as inventory: @@ -150,6 +152,8 @@ def ripgrep_inventory( message = f"{message}: {detail}" raise InventoryError(message) + if directory_guard: + return {b""} if result.returncode == 0 else set() inventory.seek(0) return set(inventory) @@ -167,13 +171,15 @@ def normalized(path: bytes) -> bytes: ancestor_scope = selected.relative_to(ancestor).as_posix() or "." ancestor_prefix = os.fsencode(ancestor.relative_to(repository).as_posix()) + b"/" visible = { - normalized(ancestor_prefix + row.removesuffix(b"\n").removeprefix(b"./")) + normalized( + ancestor_prefix + normalized(row.removesuffix(b"\n")).removeprefix(b"./") + ) for row in ripgrep_inventory(ancestor, ancestor_scope) } rows = { row for row in rows - if normalized(row.removesuffix(b"\n").removeprefix(b"./")) in visible + if normalized(row.removesuffix(b"\n")).removeprefix(b"./") in visible } environment = os.environ.copy() diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 0cd58206..448f22a1 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -243,14 +243,37 @@ export async function normalizeTarget( const candidate = isAbsolute(expandHome(value)) ? resolve(expandHome(value)) : resolve(root, expandHome(value)); + const requestedPath = relative(root, candidate); + if ( + requestedPath === ".." || + requestedPath.startsWith(`..${sep}`) || + isAbsolute(requestedPath) + ) { + throw new InvalidTargetError( + `Path target is outside the repository: ${value}`, + ); + } if (!existsSync(candidate)) { throw new InvalidTargetError(`Path target does not exist: ${value}`); } let canonical: string; try { + for ( + let current = candidate; + current !== root; + current = dirname(current) + ) { + const metadata = await abortable(() => lstat(current), signal); + if (metadata.isSymbolicLink()) { + throw new InvalidTargetError( + `Path targets must not contain symbolic links: ${value}`, + ); + } + } canonical = await abortable(() => realpath(candidate), signal); } catch (error) { throwIfAborted(signal); + if (error instanceof InvalidTargetError) throw error; throw new InvalidTargetError(`Path target does not exist: ${value}`, { cause: error, }); diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 89a5b9d6..c7dea155 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -285,7 +285,11 @@ describe("security scan file inventory", () => { const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); - for (const scope of ["parent/nested", "parent/./nested"]) { + for (const scope of [ + "parent/nested", + "parent/./nested", + "./parent/nested", + ]) { execFileSync( python, [ @@ -302,7 +306,7 @@ describe("security scan file inventory", () => { ); expect((await readFile(output, "utf8")).trim()).toBe( - "parent/nested/safe.py", + `${scope.startsWith("./") ? "./" : ""}parent/nested/safe.py`, ); } }, diff --git a/sdk/typescript/tests-ts/targets.test.ts b/sdk/typescript/tests-ts/targets.test.ts index f8894cd8..cb13f9e1 100644 --- a/sdk/typescript/tests-ts/targets.test.ts +++ b/sdk/typescript/tests-ts/targets.test.ts @@ -131,6 +131,20 @@ describe("scan target normalization", () => { ); }); + test("rejects symbolic path targets before canonicalizing them", async () => { + const repo = await repository(); + const linked = join(repo, "linked"); + await symlink( + join(repo, "src"), + linked, + process.platform === "win32" ? "junction" : "dir", + ); + + await expect(normalizeTarget(repo, ["linked/app.ts"])).rejects.toThrow( + "symbolic links", + ); + }); + test("reports a path that disappears during normalization as invalid", async () => { const repo = await repository(); const script = ` From 515fecae4a4b86c40e6b834d2d0003ba2d706425 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:32:32 -0700 Subject: [PATCH 015/106] fix(scan): reject tracked aliases and case-safe path walks --- .../scripts/generate_in_scope_files.py | 5 ++++- sdk/typescript/src/targets.ts | 8 +++---- .../tests-ts/scan-inventory.test.ts | 21 +++++++++++++++++++ sdk/typescript/tests-ts/targets.test.ts | 15 +++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) 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 29a3c6b5..0d7117fb 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -421,7 +421,10 @@ def listed_paths(index: int) -> Iterator[bytes]: if candidate.is_symlink() or not candidate.is_file(): continue try: - candidate.resolve(strict=True).relative_to(repository) + resolved = candidate.resolve(strict=True) + if resolved != candidate: + continue + resolved.relative_to(repository) except (OSError, ValueError): continue relative_path = prefix + relative diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 448f22a1..20223dff 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -258,11 +258,9 @@ export async function normalizeTarget( } let canonical: string; try { - for ( - let current = candidate; - current !== root; - current = dirname(current) - ) { + let current = root; + for (const component of requestedPath.split(sep).filter(Boolean)) { + current = join(current, component); const metadata = await abortable(() => lstat(current), signal); if (metadata.isSymbolicLink()) { throw new InvalidTargetError( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index c7dea155..72b34852 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -583,6 +583,27 @@ describe("security scan file inventory", () => { enumerate("ignored"); expect((await readFile(output, "utf8")).trim()).toBe("ignored/public.py"); + const tracked = join(repository, "tracked"); + await mkdir(tracked); + await writeFile(join(tracked, "private.py"), "previous tracked source\n"); + execFileSync("git", ["add", "--", "tracked/private.py"], { + cwd: repository, + }); + await rm(tracked, { recursive: true }); + await symlink( + ignored, + tracked, + process.platform === "win32" ? "junction" : "dir", + ); + + enumerate("."); + const rows = (await readFile(output, "utf8")) + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toContain("./ignored/public.py"); + expect(rows).not.toContain("./tracked/private.py"); + expect(rows).not.toContain("./ignored/private.py"); + const snapshot = join(root, "snapshot"); const nested = join(snapshot, "ignored"); await mkdir(nested, { recursive: true }); diff --git a/sdk/typescript/tests-ts/targets.test.ts b/sdk/typescript/tests-ts/targets.test.ts index cb13f9e1..9548888d 100644 --- a/sdk/typescript/tests-ts/targets.test.ts +++ b/sdk/typescript/tests-ts/targets.test.ts @@ -131,6 +131,21 @@ describe("scan target normalization", () => { ); }); + test("accepts case-equivalent Windows repository roots", async () => { + if (process.platform !== "win32") return; + + const repo = await repository(); + const alternate = repo.replace(/[a-z]/iu, (letter) => + letter === letter.toUpperCase() + ? letter.toLowerCase() + : letter.toUpperCase(), + ); + + expect( + await normalizeTarget(repo, [join(alternate, "src", "app.ts")]), + ).toEqual({ kind: "paths", paths: ["src/app.ts"] }); + }); + test("rejects symbolic path targets before canonicalizing them", async () => { const repo = await repository(); const linked = join(repo, "linked"); From 1e537e01e55e48043ea81ec560d13237567d0552 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:41:18 -0700 Subject: [PATCH 016/106] fix(scan): inspect every tracked path component --- .../scripts/generate_in_scope_files.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 0d7117fb..ada0dfb8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -6,6 +6,7 @@ import argparse import os import re +import stat import subprocess import sys import tempfile @@ -421,10 +422,18 @@ def listed_paths(index: int) -> Iterator[bytes]: if candidate.is_symlink() or not candidate.is_file(): continue try: - resolved = candidate.resolve(strict=True) - if resolved != candidate: + current = candidate + while current != repository: + metadata = current.stat(follow_symlinks=False) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + if stat.S_ISLNK(metadata.st_mode) or ( + getattr(metadata, "st_file_attributes", 0) & reparse_point + ): + break + current = current.parent + if current != repository: continue - resolved.relative_to(repository) + candidate.resolve(strict=True).relative_to(repository) except (OSError, ValueError): continue relative_path = prefix + relative From fc4e93c20e4283aac6828d815397d4e023721c44 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:49:14 -0700 Subject: [PATCH 017/106] fix(scan): reject Windows junctions in explicit scopes --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 6 +++++- sdk/typescript/tests-ts/scan-inventory.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 ada0dfb8..c06e4810 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -52,7 +52,11 @@ def resolve_scope(repository: Path, value: str) -> str: while current != repository: if current == current.parent: raise InventoryError("--scope: symbolic links are not supported") - if current.is_symlink(): + metadata = current.stat(follow_symlinks=False) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + if stat.S_ISLNK(metadata.st_mode) or ( + getattr(metadata, "st_file_attributes", 0) & reparse_point + ): raise InventoryError("--scope: symbolic links are not supported") current = current.parent diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 72b34852..15c2e9aa 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -611,6 +611,15 @@ describe("security scan file inventory", () => { writeFile(join(snapshot, ".gitignore"), "ignored/\n"), writeFile(join(nested, "private.py"), "ignored source\n"), ]); + const alias = join(snapshot, "alias"); + await symlink( + nested, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + expect(() => enumerate("alias/private.py", snapshot)).toThrow( + "symbolic links", + ); enumerate("ignored", snapshot); expect((await readFile(output, "utf8")).trim()).toBe(""); From 15f786b5f7aaa0584f682eb1e007a7c8188e5df5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:55:54 -0700 Subject: [PATCH 018/106] fix(scan): distinguish nested worktrees by filesystem identity --- .../scripts/generate_in_scope_files.py | 29 +++++++++++-------- .../tests-ts/scan-inventory.test.ts | 18 ++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) 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 c06e4810..352ec5e6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -100,14 +100,18 @@ def reject_symbolic_ignore(directory: Path) -> None: if any((directory / name).is_symlink() for name in IGNORE_FILE_NAMES): raise InventoryError("symbolic ignore files are not supported") - discovered_roots: set[Path] = set() + def directory_identity(path: Path) -> tuple[int, int]: + metadata = path.stat() + return metadata.st_dev, metadata.st_ino + + discovered_roots: dict[tuple[int, int], Path] = {} for ancestor in ancestors: reject_symbolic_ignore(ancestor) if selected.is_dir(): for directory, children, _ in os.walk(selected, followlinks=False): directory_path = Path(directory) if directory_path != repository and (directory_path / ".git").exists(): - discovered_roots.add(directory_path) + discovered_roots[directory_identity(directory_path)] = directory_path children[:] = [name for name in children if name != ".git"] reject_symbolic_ignore(directory_path) @@ -284,7 +288,7 @@ def listed_paths(index: int) -> Iterator[bytes]: current = selected if selected.is_dir() else selected.parent while current != repository: if (current / ".git").exists(): - nested_roots.add(current) + nested_roots[directory_identity(current)] = current current = current.parent for index in range(len(listed)): for relative in listed_paths(index): @@ -298,13 +302,14 @@ def listed_paths(index: int) -> Iterator[bytes]: discovered.relative_to(repository) except (OSError, ValueError): continue - nested_roots.add(discovered) + nested_roots[directory_identity(discovered)] = discovered - pending_roots = sorted(nested_roots) - inspected_roots: set[Path] = set() + pending_roots = sorted(nested_roots.values()) + inspected_roots: dict[tuple[int, int], Path] = {} while pending_roots: nested = pending_roots.pop(0) - if nested in inspected_roots: + nested_identity = directory_identity(nested) + if nested_identity in inspected_roots: continue nested_worktree = run_git( ["rev-parse", "--show-toplevel"], directory=nested @@ -327,7 +332,7 @@ def listed_paths(index: int) -> Iterator[bytes]: continue except (OSError, ValueError): continue - inspected_roots.add(nested) + inspected_roots[nested_identity] = nested try: nested_scope = selected.relative_to(nested).as_posix() or "." except ValueError: @@ -365,7 +370,7 @@ def listed_paths(index: int) -> Iterator[bytes]: discovered.relative_to(repository) except (OSError, ValueError): continue - if discovered not in inspected_roots: + if directory_identity(discovered) not in inspected_roots: pending_roots.append(discovered) allowed = { @@ -375,7 +380,7 @@ def listed_paths(index: int) -> Iterator[bytes]: } inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") - for root in inspected_roots + for root in inspected_roots.values() ) nested_worktrees = tuple( path @@ -383,9 +388,9 @@ def listed_paths(index: int) -> Iterator[bytes]: if path.endswith(b"/") and path not in inspected_prefixes ) explicitly_ignored = False - enclosing_roots = inspected_roots.copy() + enclosing_roots = list(inspected_roots.values()) if worktree is not None: - enclosing_roots.add(repository) + enclosing_roots.append(repository) if scope not in (".", "./") and any( selected.is_relative_to(root) for root in enclosing_roots ): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 15c2e9aa..e033e489 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -496,6 +496,23 @@ describe("security scan file inventory", () => { await writeFile(join(embedded, "hidden.py"), "print('tracked')\n"); execFileSync("git", ["add", "--", "hidden.py"], { cwd: embedded }); + const caseDistinct = join(repository, "SHARED"); + let distinctRoot = false; + try { + await mkdir(caseDistinct); + distinctRoot = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + if (distinctRoot) { + execFileSync("git", ["init", "-q"], { cwd: caseDistinct }); + await writeFile(join(caseDistinct, ".ignore"), "hidden.py\n"); + await writeFile(join(caseDistinct, "hidden.py"), "print('distinct')\n"); + execFileSync("git", ["add", "--", "hidden.py"], { + cwd: caseDistinct, + }); + } + const stale = join(repository, "vendor"); await mkdir(stale); await writeFile( @@ -527,6 +544,7 @@ describe("security scan file inventory", () => { .map((path) => path.replaceAll("\\", "/")); expect(rows).toContain("./shared/outer.py"); expect(rows).toContain("./shared/hidden.py"); + if (distinctRoot) expect(rows).toContain("./SHARED/hidden.py"); expect(rows).toContain("./vendor/source.py"); }); From bf59601b396aa80654c7e411e2cbdd3d0f4c1c5b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 15:59:58 -0700 Subject: [PATCH 019/106] fix(scan): inventory snapshots with stale Git metadata safely --- .../scripts/generate_in_scope_files.py | 16 ++++++++++++---- sdk/typescript/tests-ts/scan-inventory.test.ts | 4 ++++ 2 files changed, 16 insertions(+), 4 deletions(-) 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 352ec5e6..9506c137 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -124,6 +124,8 @@ def directory_identity(path: Path) -> tuple[int, int]: "--no-ignore-parent", "--no-ignore-global", "--glob", + "!.git", + "--glob", "!.git/**", ] @@ -250,10 +252,16 @@ def resolve_git_root(value: bytes) -> Path: ) if worktree is not None and worktree.returncode: detail = worktree.stderr.decode("utf-8", errors="replace").strip() - message = f"git rev-parse exited with status {worktree.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) + if any( + reason in detail.lower() + for reason in ("not a git repository", "gitfile does not point to a valid repository") + ): + worktree = None + else: + message = f"git rev-parse exited with status {worktree.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) if worktree is not None: try: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index e033e489..22708784 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -218,6 +218,10 @@ describe("security scan file inventory", () => { execFileSync("git", ["init", "-q"], { cwd: nested }); await writeFile(join(root, ".gitignore"), "snapshot/source.ts\n"); await Promise.all([ + writeFile( + join(repository, ".git"), + `gitdir: ${join(root, "missing-snapshot-metadata")}\n`, + ), writeFile(join(repository, ".gitignore"), ".env\n"), writeFile(join(repository, ".env"), "SECRET=private\n"), writeFile(join(repository, "source.ts"), "export {};\n"), From c6724ee3f4676e00bf387069a34641608a4dae3e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:07:45 -0700 Subject: [PATCH 020/106] fix(scan): exclude case-variant malformed Git metadata --- .../scripts/generate_in_scope_files.py | 10 +++++--- .../tests-ts/scan-inventory.test.ts | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) 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 9506c137..bb8428ac 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -123,9 +123,9 @@ def directory_identity(path: Path) -> tuple[int, int]: "--no-require-git", "--no-ignore-parent", "--no-ignore-global", - "--glob", + "--iglob", "!.git", - "--glob", + "--iglob", "!.git/**", ] @@ -254,7 +254,11 @@ def resolve_git_root(value: bytes) -> Path: detail = worktree.stderr.decode("utf-8", errors="replace").strip() if any( reason in detail.lower() - for reason in ("not a git repository", "gitfile does not point to a valid repository") + for reason in ( + "not a git repository", + "gitfile does not point to a valid repository", + "invalid gitfile format", + ) ): worktree = None else: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 22708784..0d5d8d3f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -219,7 +219,7 @@ describe("security scan file inventory", () => { await writeFile(join(root, ".gitignore"), "snapshot/source.ts\n"); await Promise.all([ writeFile( - join(repository, ".git"), + join(repository, process.platform === "win32" ? ".GIT" : ".git"), `gitdir: ${join(root, "missing-snapshot-metadata")}\n`, ), writeFile(join(repository, ".gitignore"), ".env\n"), @@ -259,6 +259,28 @@ describe("security scan file inventory", () => { "./nested/tracked.py", "./source.ts", ]); + + await writeFile( + join(repository, process.platform === "win32" ? ".GIT" : ".git"), + "malformed snapshot marker\n", + ); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + "./source.ts", + ); }); test.each([false, true])( From cf67b342b10a3e17374374c4527495d92b25270e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:16:36 -0700 Subject: [PATCH 021/106] fix(scan): consistently reject unsafe inventory metadata paths --- .../scripts/generate_in_scope_files.py | 38 +++++++++++++------ .../tests-ts/scan-inventory.test.ts | 21 ++++++++-- 2 files changed, 44 insertions(+), 15 deletions(-) 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 bb8428ac..83330bb9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -20,6 +20,13 @@ class InventoryError(ValueError): """Raised when the repository, scope, or inventory cannot be used safely.""" +def symbolic_metadata(metadata: os.stat_result) -> bool: + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) & reparse_point + ) + + def resolve_repository(value: str) -> Path: """Resolve the repository once so every scope is bound to its real root.""" try: @@ -47,16 +54,15 @@ def resolve_scope(repository: Path, value: str) -> str: relative = resolved.relative_to(repository) except ValueError as error: raise InventoryError(f"--scope: path must remain inside --repo: {value}") from error + if any(component.casefold() == ".git" for component in relative.parts): + raise InventoryError("--scope: Git metadata paths are not supported") current = scope while current != repository: if current == current.parent: raise InventoryError("--scope: symbolic links are not supported") metadata = current.stat(follow_symlinks=False) - reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) - if stat.S_ISLNK(metadata.st_mode) or ( - getattr(metadata, "st_file_attributes", 0) & reparse_point - ): + if symbolic_metadata(metadata): raise InventoryError("--scope: symbolic links are not supported") current = current.parent @@ -97,8 +103,15 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: ancestors.reverse() def reject_symbolic_ignore(directory: Path) -> None: - if any((directory / name).is_symlink() for name in IGNORE_FILE_NAMES): - raise InventoryError("symbolic ignore files are not supported") + for name in IGNORE_FILE_NAMES: + try: + metadata = (directory / name).stat(follow_symlinks=False) + except FileNotFoundError: + continue + if symbolic_metadata(metadata): + raise InventoryError("symbolic ignore files are not supported") + if not stat.S_ISREG(metadata.st_mode): + raise InventoryError("non-regular ignore files are not supported") def directory_identity(path: Path) -> tuple[int, int]: metadata = path.stat() @@ -112,7 +125,12 @@ def directory_identity(path: Path) -> tuple[int, int]: directory_path = Path(directory) if directory_path != repository and (directory_path / ".git").exists(): discovered_roots[directory_identity(directory_path)] = directory_path - children[:] = [name for name in children if name != ".git"] + children[:] = [ + name + for name in children + if name.casefold() != ".git" + and not symbolic_metadata((directory_path / name).stat(follow_symlinks=False)) + ] reject_symbolic_ignore(directory_path) command = [ @@ -333,6 +351,7 @@ def listed_paths(index: int) -> Iterator[bytes]: for reason in ( "not a git repository", "gitfile does not point to a valid repository", + "invalid gitfile format", ) ): continue @@ -446,10 +465,7 @@ def listed_paths(index: int) -> Iterator[bytes]: current = candidate while current != repository: metadata = current.stat(follow_symlinks=False) - reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) - if stat.S_ISLNK(metadata.st_mode) or ( - getattr(metadata, "st_file_attributes", 0) & reparse_point - ): + if symbolic_metadata(metadata): break current = current.parent if current != repository: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 0d5d8d3f..6e9fee5f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -281,6 +281,22 @@ describe("security scan file inventory", () => { expect((await readFile(output, "utf8")).split("\n")).toContain( "./source.ts", ); + expect(() => + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + process.platform === "win32" ? ".GIT" : ".git", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ), + ).toThrow("Git metadata paths are not supported"); }); test.each([false, true])( @@ -541,10 +557,7 @@ describe("security scan file inventory", () => { const stale = join(repository, "vendor"); await mkdir(stale); - await writeFile( - join(stale, ".git"), - `gitdir: ${join(root, "missing-git-metadata")}\n`, - ); + await writeFile(join(stale, ".git"), "malformed nested Git marker\n"); await writeFile(join(stale, "source.py"), "print('copied source')\n"); const python = From 498b40f7715a2ad1202666c46b512e41c9723400 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 16:32:29 -0700 Subject: [PATCH 022/106] fix(scan): preserve case-distinct source directories --- .../scripts/generate_in_scope_files.py | 32 ++++++++++--- .../tests-ts/scan-inventory.test.ts | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) 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 83330bb9..0fdf55a6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -27,6 +27,17 @@ def symbolic_metadata(metadata: os.stat_result) -> bool: ) +def git_metadata_path(parent: Path, name: str) -> bool: + if name == ".git": + return True + if name.casefold() != ".git": + return False + try: + return (parent / name).samefile(parent / ".git") + except OSError: + return False + + def resolve_repository(value: str) -> Path: """Resolve the repository once so every scope is bound to its real root.""" try: @@ -54,8 +65,11 @@ def resolve_scope(repository: Path, value: str) -> str: relative = resolved.relative_to(repository) except ValueError as error: raise InventoryError(f"--scope: path must remain inside --repo: {value}") from error - if any(component.casefold() == ".git" for component in relative.parts): - raise InventoryError("--scope: Git metadata paths are not supported") + parent = repository + for component in relative.parts: + if git_metadata_path(parent, component): + raise InventoryError("--scope: Git metadata paths are not supported") + parent /= component current = scope while current != repository: @@ -118,21 +132,27 @@ def directory_identity(path: Path) -> tuple[int, int]: return metadata.st_dev, metadata.st_ino discovered_roots: dict[tuple[int, int], Path] = {} + case_insensitive_metadata = False for ancestor in ancestors: reject_symbolic_ignore(ancestor) if selected.is_dir(): - for directory, children, _ in os.walk(selected, followlinks=False): + for directory, children, files in os.walk(selected, followlinks=False): directory_path = Path(directory) if directory_path != repository and (directory_path / ".git").exists(): discovered_roots[directory_identity(directory_path)] = directory_path + case_insensitive_metadata = case_insensitive_metadata or any( + name != ".git" and git_metadata_path(directory_path, name) + for name in (*children, *files) + ) children[:] = [ name for name in children - if name.casefold() != ".git" + if not git_metadata_path(directory_path, name) and not symbolic_metadata((directory_path / name).stat(follow_symlinks=False)) ] reject_symbolic_ignore(directory_path) + metadata_glob = "--iglob" if case_insensitive_metadata else "--glob" command = [ "rg", "--no-config", @@ -141,9 +161,9 @@ def directory_identity(path: Path) -> tuple[int, int]: "--no-require-git", "--no-ignore-parent", "--no-ignore-global", - "--iglob", + metadata_glob, "!.git", - "--iglob", + metadata_glob, "!.git/**", ] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6e9fee5f..7a2186cc 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -299,6 +299,51 @@ describe("security scan file inventory", () => { ).toThrow("Git metadata paths are not supported"); }); + test("preserves case-distinct Git-like source directories", async () => { + if (process.platform === "win32" || Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-case-sensitive-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "snapshot"); + await mkdir(join(repository, ".GIT"), { recursive: true }); + try { + await realpath(join(repository, ".git")); + return; + } catch { + // A case-distinct source directory exists only on case-sensitive volumes. + } + await Promise.all([ + writeFile(join(repository, ".GIT", "source.py"), "print('source')\n"), + writeFile(join(repository, "visible.py"), "print('visible')\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + const output = join(root, "in-scope-files.txt"); + for (const scope of [".", ".GIT/source.py"]) { + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + scope, + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + scope === "." ? "./.GIT/source.py" : ".GIT/source.py", + ); + } + }); + test.each([false, true])( "applies intermediate scope ignore files (Git repository: %s)", async (useGit) => { From 2105068a7249cbd8333b84c68cad671d9acfacf7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 18:23:07 -0700 Subject: [PATCH 023/106] fix(scan): scope Git aliases to real metadata --- .../scripts/generate_in_scope_files.py | 49 +++++++++++++++---- .../tests-ts/scan-inventory.test.ts | 43 ++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) 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 0fdf55a6..48eae889 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -30,7 +30,7 @@ def symbolic_metadata(metadata: os.stat_result) -> bool: def git_metadata_path(parent: Path, name: str) -> bool: if name == ".git": return True - if name.casefold() != ".git": + if name.casefold().rstrip(". ") != ".git": return False try: return (parent / name).samefile(parent / ".git") @@ -132,7 +132,7 @@ def directory_identity(path: Path) -> tuple[int, int]: return metadata.st_dev, metadata.st_ino discovered_roots: dict[tuple[int, int], Path] = {} - case_insensitive_metadata = False + metadata_aliases: set[tuple[str, ...]] = set() for ancestor in ancestors: reject_symbolic_ignore(ancestor) if selected.is_dir(): @@ -140,10 +140,11 @@ def directory_identity(path: Path) -> tuple[int, int]: directory_path = Path(directory) if directory_path != repository and (directory_path / ".git").exists(): discovered_roots[directory_identity(directory_path)] = directory_path - case_insensitive_metadata = case_insensitive_metadata or any( - name != ".git" and git_metadata_path(directory_path, name) - for name in (*children, *files) - ) + for name in (*children, *files): + if name != ".git" and git_metadata_path(directory_path, name): + metadata_aliases.add( + (directory_path / name).relative_to(repository).parts + ) children[:] = [ name for name in children @@ -152,7 +153,6 @@ def directory_identity(path: Path) -> tuple[int, int]: ] reject_symbolic_ignore(directory_path) - metadata_glob = "--iglob" if case_insensitive_metadata else "--glob" command = [ "rg", "--no-config", @@ -161,9 +161,9 @@ def directory_identity(path: Path) -> tuple[int, int]: "--no-require-git", "--no-ignore-parent", "--no-ignore-global", - metadata_glob, + "--glob", "!.git", - metadata_glob, + "--glob", "!.git/**", ] @@ -204,7 +204,18 @@ def ripgrep_inventory( if directory_guard: return {b""} if result.returncode == 0 else set() inventory.seek(0) - return set(inventory) + if not metadata_aliases: + return set(inventory) + rows = set() + directory_parts = directory.relative_to(repository).parts + for row in inventory: + parts = ( + *directory_parts, + *Path(os.fsdecode(row.removesuffix(b"\n"))).parts, + ) + if not any(parts[: len(alias)] == alias for alias in metadata_aliases): + rows.add(row) + return rows def normalized(path: bytes) -> bytes: return path.replace(b"\\", b"/") if os.name == "nt" else path @@ -429,6 +440,16 @@ def listed_paths(index: int) -> Iterator[bytes]: for index in range(len(listed)) for relative in listed_paths(index) } + visible_by_case: dict[str, list[bytes]] = {} + for row in rows: + visible = normalized(row.removesuffix(b"\n")) + visible_by_case.setdefault(os.fsdecode(visible).casefold(), []).append(visible) + for relative in listed_paths(0): + matches = visible_by_case.get( + os.fsdecode(normalized(prefix + relative)).casefold(), [] + ) + if len(matches) == 1: + allowed.add(matches[0]) inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") for root in inspected_roots.values() @@ -491,6 +512,14 @@ def listed_paths(index: int) -> Iterator[bytes]: if current != repository: continue candidate.resolve(strict=True).relative_to(repository) + if any( + visible in recorded + and candidate.samefile(repository / os.fsdecode(visible)) + for visible in visible_by_case.get( + os.fsdecode(normalized(prefix + relative)).casefold(), [] + ) + ): + continue except (OSError, ValueError): continue relative_path = prefix + relative diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 7a2186cc..7cda8c04 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, readFile, realpath, + rename, rm, symlink, writeFile, @@ -344,6 +345,48 @@ describe("security scan file inventory", () => { } }); + test("keeps tracked files after a case-only working-tree rename", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-case-renamed-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: repository }); + execFileSync("git", ["config", "core.ignoreCase", "true"], { + cwd: repository, + }); + await writeFile(join(repository, "tracked.py"), "print('tracked')\n"); + execFileSync("git", ["add", "--", "tracked.py"], { cwd: repository }); + await rename( + join(repository, "tracked.py"), + join(repository, "TRACKED.py"), + ); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe("./TRACKED.py"); + }); + test.each([false, true])( "applies intermediate scope ignore files (Git repository: %s)", async (useGit) => { From fb058c6ffb044087b7588c98005cacf1862cf060 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 18:38:34 -0700 Subject: [PATCH 024/106] fix(scan): preserve scoped case-renamed inventory --- .../scripts/generate_in_scope_files.py | 44 ++++++++++++---- .../tests-ts/scan-inventory.test.ts | 50 ++++++++++++------- 2 files changed, 67 insertions(+), 27 deletions(-) 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 48eae889..f8940747 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -171,6 +171,15 @@ def ripgrep_inventory( directory: Path, requested_scope: str, *, directory_guard: bool = False ) -> set[bytes]: arguments = command.copy() + directory_parts = directory.relative_to(repository).parts + for alias in sorted(metadata_aliases): + if alias[: len(directory_parts)] != directory_parts: + continue + relative_alias = "/".join(re.escape(part) for part in alias[len(directory_parts) :]) + if relative_alias: + arguments.extend( + ["--glob", f"!/{relative_alias}", "--glob", f"!/{relative_alias}/**"] + ) for name in IGNORE_FILE_NAMES: ignore = directory / name if ignore.is_file() and not ignore.is_symlink(): @@ -207,7 +216,6 @@ def ripgrep_inventory( if not metadata_aliases: return set(inventory) rows = set() - directory_parts = directory.relative_to(repository).parts for row in inventory: parts = ( *directory_parts, @@ -440,16 +448,32 @@ def listed_paths(index: int) -> Iterator[bytes]: for index in range(len(listed)) for relative in listed_paths(index) } - visible_by_case: dict[str, list[bytes]] = {} + visible_by_case: dict[bytes, list[bytes]] = {} for row in rows: visible = normalized(row.removesuffix(b"\n")) - visible_by_case.setdefault(os.fsdecode(visible).casefold(), []).append(visible) - for relative in listed_paths(0): - matches = visible_by_case.get( - os.fsdecode(normalized(prefix + relative)).casefold(), [] - ) - if len(matches) == 1: - allowed.add(matches[0]) + visible_by_case.setdefault(visible.lower(), []).append(visible) + tracked_candidates = list(listed_paths(0)) + if scope not in (".", "./"): + roots = ([] if worktree is None else [repository]) + list(inspected_roots.values()) + for root in roots: + tracked = run_git(["ls-files", "--cached", "-z"], directory=root) + if tracked.returncode: + detail = tracked.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError( + f"git ls-files exited with status {tracked.returncode}: {detail}" + ) + root_prefix = ( + b"" + if root == repository + else os.fsencode(root.relative_to(repository).as_posix()) + b"/" + ) + tracked_candidates.extend( + root_prefix + relative + for relative in tracked.stdout.split(b"\0") + if relative + ) + for relative in tracked_candidates: + allowed.update(visible_by_case.get(normalized(prefix + relative).lower(), [])) inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") for root in inspected_roots.values() @@ -516,7 +540,7 @@ def listed_paths(index: int) -> Iterator[bytes]: visible in recorded and candidate.samefile(repository / os.fsdecode(visible)) for visible in visible_by_case.get( - os.fsdecode(normalized(prefix + relative)).casefold(), [] + normalized(prefix + relative).lower(), [] ) ): continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 7cda8c04..ecceee93 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -354,37 +354,53 @@ describe("security scan file inventory", () => { temporaryDirectories.push(root); const repository = join(root, "repository"); const output = join(root, "in-scope-files.txt"); - await mkdir(repository); + await mkdir(join(repository, "nested"), { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: repository }); execFileSync("git", ["config", "core.ignoreCase", "true"], { cwd: repository, }); await writeFile(join(repository, "tracked.py"), "print('tracked')\n"); - execFileSync("git", ["add", "--", "tracked.py"], { cwd: repository }); + await writeFile( + join(repository, "nested", "source.py"), + "print('nested')\n", + ); + execFileSync("git", ["add", "--", "tracked.py", "nested/source.py"], { + cwd: repository, + }); await rename( join(repository, "tracked.py"), join(repository, "TRACKED.py"), ); + await rename(join(repository, "nested"), join(repository, "NESTED")); const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); - execFileSync( - python, - [ - "-B", - join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), - "--repo", - repository, - "--scope", - ".", - "--out", - output, - ], - { cwd: repository, stdio: "pipe" }, - ); + for (const [scope, expected] of [ + [".", ["./NESTED/source.py", "./TRACKED.py"]], + ["TRACKED.py", ["TRACKED.py"]], + ["NESTED", ["NESTED/source.py"]], + ["NESTED/source.py", ["NESTED/source.py"]], + ] as const) { + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + scope, + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); - expect((await readFile(output, "utf8")).trim()).toBe("./TRACKED.py"); + expect((await readFile(output, "utf8")).trim().split("\n")).toEqual([ + ...expected, + ]); + } }); test.each([false, true])( From 8772c16c025a96eb32148ae22aff55360c1a5b78 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 18:51:05 -0700 Subject: [PATCH 025/106] fix(scan): bind tracked recovery to its Git worktree --- .../scripts/generate_in_scope_files.py | 152 +++++++++++------- .../tests-ts/scan-inventory.test.ts | 1 + 2 files changed, 93 insertions(+), 60 deletions(-) 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 f8940747..cadaa7e2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -11,7 +11,7 @@ import sys import tempfile from collections.abc import Iterator -from pathlib import Path +from pathlib import Path, PurePosixPath IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") @@ -172,26 +172,31 @@ def ripgrep_inventory( ) -> set[bytes]: arguments = command.copy() directory_parts = directory.relative_to(repository).parts + ignored_aliases = [] for alias in sorted(metadata_aliases): if alias[: len(directory_parts)] != directory_parts: continue relative_alias = "/".join(re.escape(part) for part in alias[len(directory_parts) :]) - if relative_alias: - arguments.extend( - ["--glob", f"!/{relative_alias}", "--glob", f"!/{relative_alias}/**"] - ) + if relative_alias and "\n" not in relative_alias and "\r" not in relative_alias: + ignored_aliases.append(f"/{relative_alias}\n") for name in IGNORE_FILE_NAMES: ignore = directory / name if ignore.is_file() and not ignore.is_symlink(): arguments.extend(["--ignore-file", str(ignore)]) - if directory_guard: - relative_scope = requested_scope.removeprefix("./") - arguments.extend( - ["--quiet", "--glob", f"/{re.escape(relative_scope)}/**", "--", "."] - ) - else: - arguments.extend(["--", requested_scope]) - with tempfile.TemporaryFile(mode="w+b") as inventory: + with tempfile.TemporaryDirectory() as temporary_directory, tempfile.TemporaryFile( + mode="w+b" + ) as inventory: + if ignored_aliases: + alias_file = Path(temporary_directory) / "git-metadata.ignore" + alias_file.write_bytes(b"".join(os.fsencode(alias) for alias in ignored_aliases)) + arguments.extend(["--ignore-file", str(alias_file)]) + if directory_guard: + relative_scope = requested_scope.removeprefix("./") + arguments.extend( + ["--quiet", "--glob", f"/{re.escape(relative_scope)}/**", "--", "."] + ) + else: + arguments.extend(["--", requested_scope]) try: result = subprocess.run( arguments, @@ -335,6 +340,7 @@ def resolve_git_root(value: bytes) -> Path: if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" listed: list[list[bytes]] = [[], []] + cached_by_root: dict[Path, list[bytes]] = {} def listed_paths(index: int) -> Iterator[bytes]: for chunk in listed[index]: @@ -352,6 +358,10 @@ def listed_paths(index: int) -> Iterator[bytes]: message = f"{message}: {detail}" raise InventoryError(message) listed[index].append(result.stdout) + if index == 0: + cached_by_root[repository] = [ + relative for relative in result.stdout.split(b"\0") if relative + ] nested_roots = discovered_roots.copy() current = selected if selected.is_dir() else selected.parent @@ -427,6 +437,10 @@ def listed_paths(index: int) -> Iterator[bytes]: if relative ) ) + if index == 0: + cached_by_root[nested] = [ + relative for relative in result.stdout.split(b"\0") if relative + ] for relative in result.stdout.split(b"\0"): if not relative: continue @@ -448,32 +462,17 @@ def listed_paths(index: int) -> Iterator[bytes]: for index in range(len(listed)) for relative in listed_paths(index) } - visible_by_case: dict[bytes, list[bytes]] = {} - for row in rows: - visible = normalized(row.removesuffix(b"\n")) - visible_by_case.setdefault(visible.lower(), []).append(visible) - tracked_candidates = list(listed_paths(0)) if scope not in (".", "./"): - roots = ([] if worktree is None else [repository]) + list(inspected_roots.values()) - for root in roots: + for root in cached_by_root: tracked = run_git(["ls-files", "--cached", "-z"], directory=root) if tracked.returncode: detail = tracked.stderr.decode("utf-8", errors="replace").strip() raise InventoryError( f"git ls-files exited with status {tracked.returncode}: {detail}" ) - root_prefix = ( - b"" - if root == repository - else os.fsencode(root.relative_to(repository).as_posix()) + b"/" - ) - tracked_candidates.extend( - root_prefix + relative - for relative in tracked.stdout.split(b"\0") - if relative - ) - for relative in tracked_candidates: - allowed.update(visible_by_case.get(normalized(prefix + relative).lower(), [])) + cached_by_root[root] = [ + relative for relative in tracked.stdout.split(b"\0") if relative + ] inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") for root in inspected_roots.values() @@ -521,36 +520,69 @@ def listed_paths(index: int) -> Iterator[bytes]: or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} - - for relative in listed_paths(0): - candidate = repository / os.fsdecode(relative) - if candidate.is_symlink() or not candidate.is_file(): - continue - try: - current = candidate - while current != repository: - metadata = current.stat(follow_symlinks=False) - if symbolic_metadata(metadata): - break - current = current.parent - if current != repository: + directory_entries: dict[Path, dict[bytes, list[Path]]] = {} + + def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: + components = PurePosixPath(os.fsdecode(relative)).parts + if not components or any(part in (".", "..") for part in components): + return + candidates = [root] + for index, component in enumerate(components): + matches: list[Path] = [] + for parent in candidates: + if parent not in directory_entries: + grouped: dict[bytes, list[Path]] = {} + try: + with os.scandir(parent) as entries: + for entry in entries: + if not git_metadata_path(parent, entry.name): + grouped.setdefault( + os.fsencode(entry.name).lower(), [] + ).append(parent / entry.name) + except OSError: + continue + directory_entries[parent] = grouped + for candidate in directory_entries[parent].get( + os.fsencode(component).lower(), [] + ): + try: + metadata = candidate.stat(follow_symlinks=False) + except OSError: + continue + if symbolic_metadata(metadata): + continue + if index + 1 < len(components): + if not stat.S_ISDIR(metadata.st_mode): + continue + owner = inspected_roots.get((metadata.st_dev, metadata.st_ino)) + if owner is not None and owner != root: + continue + elif not stat.S_ISREG(metadata.st_mode): + continue + matches.append(candidate) + candidates = matches + for candidate in candidates: + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(repository) + if selected.is_dir(): + resolved.relative_to(selected) + elif resolved != selected: + continue + except (OSError, ValueError): continue - candidate.resolve(strict=True).relative_to(repository) - if any( - visible in recorded - and candidate.samefile(repository / os.fsdecode(visible)) - for visible in visible_by_case.get( - normalized(prefix + relative).lower(), [] + yield candidate + + for root, tracked_paths in cached_by_root.items(): + for relative in tracked_paths: + for candidate in tracked_variants(root, relative): + relative_path = prefix + os.fsencode( + candidate.relative_to(repository).as_posix() ) - ): - continue - except (OSError, ValueError): - continue - relative_path = prefix + relative - key = normalized(relative_path) - if key not in recorded: - rows.add(relative_path + b"\n") - recorded.add(key) + key = normalized(relative_path) + if key not in recorded: + rows.add(relative_path + b"\n") + recorded.add(key) rows = sorted(rows) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ecceee93..461a1feb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -364,6 +364,7 @@ describe("security scan file inventory", () => { join(repository, "nested", "source.py"), "print('nested')\n", ); + await writeFile(join(repository, ".ignore"), "source.py\n.ignore\n"); execFileSync("git", ["add", "--", "tracked.py", "nested/source.py"], { cwd: repository, }); From 3e7917a842a279890759ee4a8e5e6efeb52397cc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 18:53:30 -0700 Subject: [PATCH 026/106] fix(scan): respect each worktree case setting --- .../scripts/generate_in_scope_files.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 cadaa7e2..c5ce52fc 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -473,6 +473,15 @@ def listed_paths(index: int) -> Iterator[bytes]: cached_by_root[root] = [ relative for relative in tracked.stdout.split(b"\0") if relative ] + case_insensitive_roots: dict[Path, bool] = {} + for root in cached_by_root: + setting = run_git(["config", "--bool", "core.ignoreCase"], directory=root) + if setting.returncode not in (0, 1): + detail = setting.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError( + f"git config exited with status {setting.returncode}: {detail}" + ) + case_insensitive_roots[root] = setting.stdout.strip().lower() == b"true" inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") for root in inspected_roots.values() @@ -542,9 +551,15 @@ def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: except OSError: continue directory_entries[parent] = grouped - for candidate in directory_entries[parent].get( + variants = directory_entries[parent].get( os.fsencode(component).lower(), [] - ): + ) + exact = [candidate for candidate in variants if candidate.name == component] + if exact: + variants = exact + elif not case_insensitive_roots[root]: + continue + for candidate in variants: try: metadata = candidate.stat(follow_symlinks=False) except OSError: From 708ded5d894f9e73403fc602ad508abff2292697 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 19:04:33 -0700 Subject: [PATCH 027/106] fix(scan): key scoped inventory by filesystem identity --- .../scripts/generate_in_scope_files.py | 142 +++++++++++------- .../tests-ts/scan-inventory.test.ts | 5 + 2 files changed, 93 insertions(+), 54 deletions(-) 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 c5ce52fc..7e070692 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -340,7 +340,7 @@ def resolve_git_root(value: bytes) -> Path: if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" listed: list[list[bytes]] = [[], []] - cached_by_root: dict[Path, list[bytes]] = {} + cached_by_root: dict[tuple[int, int], tuple[Path, list[bytes]]] = {} def listed_paths(index: int) -> Iterator[bytes]: for chunk in listed[index]: @@ -359,9 +359,10 @@ def listed_paths(index: int) -> Iterator[bytes]: raise InventoryError(message) listed[index].append(result.stdout) if index == 0: - cached_by_root[repository] = [ - relative for relative in result.stdout.split(b"\0") if relative - ] + cached_by_root[directory_identity(repository)] = ( + repository, + [relative for relative in result.stdout.split(b"\0") if relative], + ) nested_roots = discovered_roots.copy() current = selected if selected.is_dir() else selected.parent @@ -438,9 +439,10 @@ def listed_paths(index: int) -> Iterator[bytes]: ) ) if index == 0: - cached_by_root[nested] = [ - relative for relative in result.stdout.split(b"\0") if relative - ] + cached_by_root[nested_identity] = ( + nested, + [relative for relative in result.stdout.split(b"\0") if relative], + ) for relative in result.stdout.split(b"\0"): if not relative: continue @@ -463,25 +465,26 @@ def listed_paths(index: int) -> Iterator[bytes]: for relative in listed_paths(index) } if scope not in (".", "./"): - for root in cached_by_root: + for identity, (root, _) in list(cached_by_root.items()): tracked = run_git(["ls-files", "--cached", "-z"], directory=root) if tracked.returncode: detail = tracked.stderr.decode("utf-8", errors="replace").strip() raise InventoryError( f"git ls-files exited with status {tracked.returncode}: {detail}" ) - cached_by_root[root] = [ - relative for relative in tracked.stdout.split(b"\0") if relative - ] - case_insensitive_roots: dict[Path, bool] = {} - for root in cached_by_root: + cached_by_root[identity] = ( + root, + [relative for relative in tracked.stdout.split(b"\0") if relative], + ) + case_insensitive_roots: dict[tuple[int, int], bool] = {} + for identity, (root, _) in cached_by_root.items(): setting = run_git(["config", "--bool", "core.ignoreCase"], directory=root) if setting.returncode not in (0, 1): detail = setting.stderr.decode("utf-8", errors="replace").strip() raise InventoryError( f"git config exited with status {setting.returncode}: {detail}" ) - case_insensitive_roots[root] = setting.stdout.strip().lower() == b"true" + case_insensitive_roots[identity] = setting.stdout.strip().lower() == b"true" inspected_prefixes = tuple( normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") for root in inspected_roots.values() @@ -529,37 +532,61 @@ def listed_paths(index: int) -> Iterator[bytes]: or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} - directory_entries: dict[Path, dict[bytes, list[Path]]] = {} + directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} + selected_parts = tuple( + os.fsencode(part) for part in selected.relative_to(repository).parts + ) + selected_is_directory = selected.is_dir() - def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: + def tracked_variants( + root_identity: tuple[int, int], root: Path, relative: bytes + ) -> Iterator[Path]: components = PurePosixPath(os.fsdecode(relative)).parts if not components or any(part in (".", "..") for part in components): return - candidates = [root] - for index, component in enumerate(components): - matches: list[Path] = [] - for parent in candidates: - if parent not in directory_entries: - grouped: dict[bytes, list[Path]] = {} - try: - with os.scandir(parent) as entries: - for entry in entries: - if not git_metadata_path(parent, entry.name): - grouped.setdefault( - os.fsencode(entry.name).lower(), [] - ).append(parent / entry.name) - except OSError: - continue - directory_entries[parent] = grouped - variants = directory_entries[parent].get( - os.fsencode(component).lower(), [] - ) - exact = [candidate for candidate in variants if candidate.name == component] - if exact: - variants = exact - elif not case_insensitive_roots[root]: - continue - for candidate in variants: + root_parts = tuple(os.fsencode(part) for part in root.relative_to(repository).parts) + indexed_parts = root_parts + tuple(os.fsencode(part) for part in components) + if (not selected_is_directory and len(indexed_parts) != len(selected_parts)) or ( + selected_is_directory and len(indexed_parts) <= len(selected_parts) + ): + return + for index, requested in enumerate(selected_parts): + indexed = indexed_parts[index] + if index < len(root_parts) or not case_insensitive_roots[root_identity]: + if indexed != requested: + return + elif indexed.lower() != requested.lower(): + return + + def descend(parent: Path, index: int) -> list[Path]: + try: + parent_identity = directory_identity(parent) + except OSError: + return [] + if parent_identity not in directory_entries: + grouped: dict[bytes, list[Path]] = {} + try: + with os.scandir(parent) as entries: + for entry in entries: + if not git_metadata_path(parent, entry.name): + grouped.setdefault( + os.fsencode(entry.name).lower(), [] + ).append(parent / entry.name) + except OSError: + return [] + directory_entries[parent_identity] = grouped + component = components[index] + variants = directory_entries[parent_identity].get( + os.fsencode(component).lower(), [] + ) + exact = [candidate for candidate in variants if candidate.name == component] + alternatives = [candidate for candidate in variants if candidate.name != component] + groups = [exact] + if case_insensitive_roots[root_identity]: + groups.append(alternatives) + for group in groups: + matches: list[Path] = [] + for candidate in group: try: metadata = candidate.stat(follow_symlinks=False) except OSError: @@ -569,28 +596,35 @@ def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: if index + 1 < len(components): if not stat.S_ISDIR(metadata.st_mode): continue - owner = inspected_roots.get((metadata.st_dev, metadata.st_ino)) - if owner is not None and owner != root: + owner_identity = (metadata.st_dev, metadata.st_ino) + if owner_identity in inspected_roots and owner_identity != root_identity: continue - elif not stat.S_ISREG(metadata.st_mode): - continue - matches.append(candidate) - candidates = matches - for candidate in candidates: + matches.extend(descend(candidate, index + 1)) + elif stat.S_ISREG(metadata.st_mode): + matches.append(candidate) + if matches: + return matches + return [] + + for candidate in descend(root, 0): try: resolved = candidate.resolve(strict=True) resolved.relative_to(repository) - if selected.is_dir(): - resolved.relative_to(selected) - elif resolved != selected: - continue except (OSError, ValueError): continue + candidate_parts = tuple( + os.fsencode(part) for part in candidate.relative_to(repository).parts + ) + if selected_is_directory: + if candidate_parts[: len(selected_parts)] != selected_parts: + continue + elif candidate_parts != selected_parts: + continue yield candidate - for root, tracked_paths in cached_by_root.items(): + for root_identity, (root, tracked_paths) in cached_by_root.items(): for relative in tracked_paths: - for candidate in tracked_variants(root, relative): + for candidate in tracked_variants(root_identity, root, relative): relative_path = prefix + os.fsencode( candidate.relative_to(repository).as_posix() ) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 461a1feb..0d5e9221 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -373,6 +373,11 @@ describe("security scan file inventory", () => { join(repository, "TRACKED.py"), ); await rename(join(repository, "nested"), join(repository, "NESTED")); + try { + await mkdir(join(repository, "nested")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); From f5289bd83b777e750c1d0eb208e0e28add4b8f08 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 22:39:48 -0700 Subject: [PATCH 028/106] fix(scan): keep ignored checkouts and unsafe paths out of scope --- .../scripts/generate_in_scope_files.py | 53 +++++++++--- .../tests-ts/scan-inventory.test.ts | 81 +++++++++++++++++-- 2 files changed, 117 insertions(+), 17 deletions(-) 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 7e070692..3c8f66eb 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -151,7 +151,6 @@ def directory_identity(path: Path) -> tuple[int, int]: if not git_metadata_path(directory_path, name) and not symbolic_metadata((directory_path / name).stat(follow_symlinks=False)) ] - reject_symbolic_ignore(directory_path) command = [ "rg", @@ -234,6 +233,14 @@ def normalized(path: bytes) -> bytes: return path.replace(b"\\", b"/") if os.name == "nt" else path rows = ripgrep_inventory(repository, scope) + validated_directories = set(ancestors) + for row in rows: + relative = normalized(row.removesuffix(b"\n")).removeprefix(b"./") + directory = (repository / os.fsdecode(relative)).parent + while directory != repository and directory not in validated_directories: + reject_symbolic_ignore(directory) + validated_directories.add(directory) + directory = directory.parent if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( repository, scope, directory_guard=True ): @@ -344,7 +351,12 @@ def resolve_git_root(value: bytes) -> Path: def listed_paths(index: int) -> Iterator[bytes]: for chunk in listed[index]: - yield from (relative for relative in chunk.split(b"\0") if relative) + for relative in chunk.split(b"\0"): + if not relative: + continue + if b"\n" in relative or b"\r" in relative: + raise InventoryError("line separators are not supported in inventory paths") + yield relative if worktree is not None: for index, arguments in enumerate( @@ -364,7 +376,26 @@ def listed_paths(index: int) -> Iterator[bytes]: [relative for relative in result.stdout.split(b"\0") if relative], ) - nested_roots = discovered_roots.copy() + visible_paths = { + normalized(row.removesuffix(b"\n")).removeprefix(b"./") for row in rows + } + outer_tracked_paths = set(listed_paths(0)) + + def visible_nested_root(root: Path) -> bool: + relative = os.fsencode(root.relative_to(repository).as_posix()) + if selected != repository and (selected == root or selected.is_relative_to(root)): + return True + return any( + path == relative or path.startswith(relative + b"/") + for paths in (visible_paths, outer_tracked_paths) + for path in paths + ) + + nested_roots = { + identity: root + for identity, root in discovered_roots.items() + if visible_nested_root(root) + } current = selected if selected.is_dir() else selected.parent while current != repository: if (current / ".git").exists(): @@ -532,7 +563,7 @@ def listed_paths(index: int) -> Iterator[bytes]: or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} - directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} + directory_entries: dict[tuple[int, int], dict[str, list[Path]]] = {} selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts ) @@ -555,7 +586,7 @@ def tracked_variants( if index < len(root_parts) or not case_insensitive_roots[root_identity]: if indexed != requested: return - elif indexed.lower() != requested.lower(): + elif os.fsdecode(indexed).casefold() != os.fsdecode(requested).casefold(): return def descend(parent: Path, index: int) -> list[Path]: @@ -564,21 +595,19 @@ def descend(parent: Path, index: int) -> list[Path]: except OSError: return [] if parent_identity not in directory_entries: - grouped: dict[bytes, list[Path]] = {} + grouped: dict[str, list[Path]] = {} try: with os.scandir(parent) as entries: for entry in entries: if not git_metadata_path(parent, entry.name): - grouped.setdefault( - os.fsencode(entry.name).lower(), [] - ).append(parent / entry.name) + grouped.setdefault(entry.name.casefold(), []).append( + parent / entry.name + ) except OSError: return [] directory_entries[parent_identity] = grouped component = components[index] - variants = directory_entries[parent_identity].get( - os.fsencode(component).lower(), [] - ) + variants = directory_entries[parent_identity].get(component.casefold(), []) exact = [candidate for candidate in variants if candidate.name == component] alternatives = [candidate for candidate in variants if candidate.name != component] groups = [exact] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 0d5e9221..4ebf1063 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -204,6 +204,44 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32")( + "rejects tracked paths that cannot fit in a line-based inventory", + async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-newline-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: repository }); + const unsafePath = "tracked\nprivate.env"; + await writeFile(join(repository, unsafePath), "tracked source\n"); + execFileSync("git", ["add", "--", unsafePath], { cwd: repository }); + const python = Bun.which("python3") ?? Bun.which("python"); + if (python === null) throw new Error("A Python interpreter is required."); + + expect(() => + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ), + ).toThrow("line separators are not supported"); + }, + ); + test("respects ignore files in non-Git directory snapshots", async () => { if (Bun.which("rg") === null) return; @@ -360,18 +398,27 @@ describe("security scan file inventory", () => { cwd: repository, }); await writeFile(join(repository, "tracked.py"), "print('tracked')\n"); + await writeFile(join(repository, "ÄUTH.py"), "print('unicode')\n"); await writeFile( join(repository, "nested", "source.py"), "print('nested')\n", ); - await writeFile(join(repository, ".ignore"), "source.py\n.ignore\n"); - execFileSync("git", ["add", "--", "tracked.py", "nested/source.py"], { - cwd: repository, - }); + await writeFile( + join(repository, ".ignore"), + "source.py\näuth.py\n.ignore\n", + ); + execFileSync( + "git", + ["add", "--", "tracked.py", "ÄUTH.py", "nested/source.py"], + { + cwd: repository, + }, + ); await rename( join(repository, "tracked.py"), join(repository, "TRACKED.py"), ); + await rename(join(repository, "ÄUTH.py"), join(repository, "äuth.py")); await rename(join(repository, "nested"), join(repository, "NESTED")); try { await mkdir(join(repository, "nested")); @@ -383,8 +430,9 @@ describe("security scan file inventory", () => { Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) throw new Error("A Python interpreter is required."); for (const [scope, expected] of [ - [".", ["./NESTED/source.py", "./TRACKED.py"]], + [".", ["./NESTED/source.py", "./TRACKED.py", "./äuth.py"]], ["TRACKED.py", ["TRACKED.py"]], + ["äuth.py", ["äuth.py"]], ["NESTED", ["NESTED/source.py"]], ["NESTED/source.py", ["NESTED/source.py"]], ] as const) { @@ -600,6 +648,29 @@ describe("security scan file inventory", () => { { cwd: nested }, ); await writeFile(join(repository, ".gitignore"), "nested/\n"); + if (process.platform !== "win32") { + await symlink(join(repository, ".gitignore"), join(nested, ".ignore")); + } + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).not.toContain( + "./nested/tracked.py", + ); + if (process.platform !== "win32") { + await rm(join(nested, ".ignore")); + } execFileSync("git", ["add", "--force", "--", "nested"], { cwd: repository, stdio: "ignore", From 303467c127a1331a1d49bcc025ac89392f4bc25b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 22:59:52 -0700 Subject: [PATCH 029/106] fix(scan): preserve ignore boundaries before inventory recovery --- .../scripts/generate_in_scope_files.py | 51 ++++++++++++++----- .../tests-ts/scan-inventory.test.ts | 36 ++++++++++--- 2 files changed, 67 insertions(+), 20 deletions(-) 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 3c8f66eb..e8c5f876 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -116,16 +116,37 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: current = current.parent ancestors.reverse() - def reject_symbolic_ignore(directory: Path) -> None: + def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> None: for name in IGNORE_FILE_NAMES: try: metadata = (directory / name).stat(follow_symlinks=False) except FileNotFoundError: continue + if not symbolic_metadata(metadata) and stat.S_ISREG(metadata.st_mode): + continue + if allow_ignored and directory != repository: + ignored = subprocess.run( + [ + "git", + "-c", + "core.fsmonitor=false", + "-C", + str(repository), + "check-ignore", + "--quiet", + "--no-index", + "--", + directory.relative_to(repository).as_posix(), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if ignored.returncode == 0: + continue if symbolic_metadata(metadata): raise InventoryError("symbolic ignore files are not supported") - if not stat.S_ISREG(metadata.st_mode): - raise InventoryError("non-regular ignore files are not supported") + raise InventoryError("non-regular ignore files are not supported") def directory_identity(path: Path) -> tuple[int, int]: metadata = path.stat() @@ -151,11 +172,13 @@ def directory_identity(path: Path) -> tuple[int, int]: if not git_metadata_path(directory_path, name) and not symbolic_metadata((directory_path / name).stat(follow_symlinks=False)) ] + reject_symbolic_ignore(directory_path, allow_ignored=True) command = [ "rg", "--no-config", "--files", + "--null", "--hidden", "--no-require-git", "--no-ignore-parent", @@ -217,10 +240,16 @@ def ripgrep_inventory( if directory_guard: return {b""} if result.returncode == 0 else set() inventory.seek(0) - if not metadata_aliases: - return set(inventory) rows = set() - for row in inventory: + for path in inventory.read().split(b"\0"): + if not path: + continue + if b"\n" in path or b"\r" in path: + raise InventoryError("line separators are not supported in inventory paths") + row = path + b"\n" + if not metadata_aliases: + rows.add(row) + continue parts = ( *directory_parts, *Path(os.fsdecode(row.removesuffix(b"\n"))).parts, @@ -233,14 +262,6 @@ def normalized(path: bytes) -> bytes: return path.replace(b"\\", b"/") if os.name == "nt" else path rows = ripgrep_inventory(repository, scope) - validated_directories = set(ancestors) - for row in rows: - relative = normalized(row.removesuffix(b"\n")).removeprefix(b"./") - directory = (repository / os.fsdecode(relative)).parent - while directory != repository and directory not in validated_directories: - reject_symbolic_ignore(directory) - validated_directories.add(directory) - directory = directory.parent if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( repository, scope, directory_guard=True ): @@ -413,6 +434,8 @@ def visible_nested_root(root: Path) -> bool: discovered.relative_to(repository) except (OSError, ValueError): continue + if index != 0 and not visible_nested_root(discovered): + continue nested_roots[directory_identity(discovered)] = discovered pending_roots = sorted(nested_roots.values()) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4ebf1063..1b96f540 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -204,10 +204,10 @@ describe("security scan file inventory", () => { }, ); - test.skipIf(process.platform === "win32")( - "rejects tracked paths that cannot fit in a line-based inventory", - async () => { - if (Bun.which("rg") === null) return; + test.each([false, true])( + "rejects paths that cannot fit in a line-based inventory (Git repository: %s)", + async (useGit) => { + if (process.platform === "win32" || Bun.which("rg") === null) return; const root = await realpath( await mkdtemp(join(tmpdir(), "codex-security-newline-inventory-")), @@ -216,10 +216,12 @@ describe("security scan file inventory", () => { const repository = join(root, "repository"); const output = join(root, "in-scope-files.txt"); await mkdir(repository); - execFileSync("git", ["init", "-q"], { cwd: repository }); const unsafePath = "tracked\nprivate.env"; await writeFile(join(repository, unsafePath), "tracked source\n"); - execFileSync("git", ["add", "--", unsafePath], { cwd: repository }); + if (useGit) { + execFileSync("git", ["init", "-q"], { cwd: repository }); + execFileSync("git", ["add", "--", unsafePath], { cwd: repository }); + } const python = Bun.which("python3") ?? Bun.which("python"); if (python === null) throw new Error("A Python interpreter is required."); @@ -647,6 +649,28 @@ describe("security scan file inventory", () => { ], { cwd: nested }, ); + for (const ignoreFile of [".ignore", ".rgignore"]) { + await writeFile(join(repository, ignoreFile), "nested/\n"); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).not.toContain( + "./nested/tracked.py", + ); + await rm(join(repository, ignoreFile)); + } + await writeFile(join(repository, ".gitignore"), "nested/\n"); if (process.platform !== "win32") { await symlink(join(repository, ".gitignore"), join(nested, ".ignore")); From b9bdad08ec0c45fa2e56d870419091c3ea78285d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 10 Aug 2026 23:08:56 -0700 Subject: [PATCH 030/106] fix(scan): stream inventory and honor ripgrep overrides --- .../scripts/generate_in_scope_files.py | 21 +++++++++++++++++-- .../tests-ts/scan-inventory.test.ts | 18 ++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) 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 e8c5f876..b7eb380b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import io import os import re import stat @@ -142,7 +143,12 @@ def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> N stderr=subprocess.DEVNULL, check=False, ) - if ignored.returncode == 0: + ripgrep_overrides = any( + (repository / parent / ignore).is_file() + for parent in directory.relative_to(repository).parents + for ignore in (".ignore", ".rgignore") + ) + if ignored.returncode == 0 and not ripgrep_overrides: continue if symbolic_metadata(metadata): raise InventoryError("symbolic ignore files are not supported") @@ -241,7 +247,18 @@ def ripgrep_inventory( return {b""} if result.returncode == 0 else set() inventory.seek(0) rows = set() - for path in inventory.read().split(b"\0"): + + def inventory_paths() -> Iterator[bytes]: + remainder = b"" + while chunk := inventory.read(io.DEFAULT_BUFFER_SIZE): + paths = chunk.split(b"\0") + paths[0] = remainder + paths[0] + remainder = paths.pop() + yield from paths + if remainder: + raise InventoryError("ripgrep returned an unterminated inventory path") + + for path in inventory_paths(): if not path: continue if b"\n" in path or b"\r" in path: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 1b96f540..b202cdef 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -693,6 +693,24 @@ describe("security scan file inventory", () => { "./nested/tracked.py", ); if (process.platform !== "win32") { + await writeFile(join(repository, ".rgignore"), "!nested/\n"); + expect(() => + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ), + ).toThrow("symbolic ignore files are not supported"); + await rm(join(repository, ".rgignore")); await rm(join(nested, ".ignore")); } execFileSync("git", ["add", "--force", "--", "nested"], { From 2a1ac7107aad582dcafdbd48fb700457ead29fe6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 00:26:27 -0700 Subject: [PATCH 031/106] fix(inventory): respect outer ignores and preserve explicit readable files --- .../scripts/generate_in_scope_files.py | 105 ++++++++++++++++-- .../tests-ts/scan-inventory.test.ts | 59 ++++++++++ 2 files changed, 154 insertions(+), 10 deletions(-) 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 bfcad83d..8b91aa68 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -272,9 +272,18 @@ def normalized(path: bytes) -> bytes: reject_symbolic_ignore(directory, allow_ignored=True) if directory != repository and (directory / ".git").exists(): discovered_roots[directory_identity(directory)] = directory + if not selected.is_dir(): + continue for entry in directory.iterdir(): if entry.name != ".git" and git_metadata_path(directory, entry.name): metadata_aliases.add(entry.relative_to(repository).parts) + elif ( + entry.name != ".git" + and not entry.is_symlink() + and entry.is_dir() + and (entry / ".git").exists() + ): + discovered_roots[directory_identity(entry)] = entry if metadata_aliases: rows = { row @@ -305,6 +314,53 @@ def normalized(path: bytes) -> bytes: if normalized(row.removesuffix(b"\n")).removeprefix(b"./") in visible } + def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: + requested = { + normalized(os.fsencode(candidate.relative_to(repository).as_posix())) + for candidate in candidates + } + directories: list[Path] = [] + current = root.parent + while True: + directories.append(current) + if current == repository: + break + current = current.parent + ignore_files = [ + directory / name + for directory in directories + for name in IGNORE_FILE_NAMES + if (directory / name).is_file() + ] + if not ignore_files: + return requested + + with tempfile.TemporaryDirectory() as temporary_directory: + probe = Path(temporary_directory) + for ignore in ignore_files: + destination = probe / ignore.relative_to(repository) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(ignore.read_bytes()) + for relative in requested: + destination = probe / os.fsdecode(relative) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.touch() + result = subprocess.run( + [*command, "--", "."], + cwd=probe, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode not in (0, 1): + detail = result.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError(f"could not evaluate outer ignore rules: {detail}") + return { + normalized(relative).removeprefix(b"./") + for relative in result.stdout.split(b"\0") + if normalized(relative).removeprefix(b"./") in requested + } + environment = os.environ.copy() for name in ( "GIT_ALTERNATE_OBJECT_DIRECTORIES", @@ -428,11 +484,14 @@ def visible_nested_root(root: Path) -> bool: relative = os.fsencode(root.relative_to(repository).as_posix()) if selected != repository and (selected == root or selected.is_relative_to(root)): return True - return any( + if any( path == relative or path.startswith(relative + b"/") for paths in (visible_paths, outer_tracked_paths) for path in paths - ) + ): + return True + marker = root / ".codex-security-inventory-probe" + return bool(visible_to_outer_ignores(root, [marker])) nested_roots = { identity: root @@ -600,6 +659,11 @@ def visible_nested_root(root: Path) -> bool: row for row in rows if (path := normalized(row.removesuffix(b"\n"))) in allowed + or ( + selected.is_file() + and path.removeprefix(b"./") + == os.fsencode(selected.relative_to(repository).as_posix()) + ) or ( worktree is None and not any(path.startswith(root) for root in inspected_prefixes) @@ -696,15 +760,36 @@ def descend(parent: Path, index: int) -> list[Path]: yield candidate for root_identity, (root, tracked_paths) in cached_by_root.items(): - for relative in tracked_paths: - for candidate in tracked_variants(root_identity, root, relative): - relative_path = prefix + os.fsencode( - candidate.relative_to(repository).as_posix() + candidates = [ + candidate + for relative in tracked_paths + for candidate in tracked_variants(root_identity, root, relative) + ] + outer_visible = ( + visible_to_outer_ignores(root, candidates) + if root != repository + and not ( + selected != repository + and (selected == root or selected.is_relative_to(root)) + ) + else None + ) + for candidate in candidates: + relative = os.fsencode(candidate.relative_to(repository).as_posix()) + if ( + outer_visible is not None + and normalized(relative) not in outer_visible + and not any( + relative == tracked or relative.startswith(tracked + b"/") + for tracked in outer_tracked_paths ) - key = normalized(relative_path) - if key not in recorded: - rows.add(relative_path + b"\n") - recorded.add(key) + ): + continue + relative_path = prefix + relative + key = normalized(relative_path) + if key not in recorded: + rows.add(relative_path + b"\n") + recorded.add(key) rows = sorted(rows) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 579138d5..17966989 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { + chmod, mkdir, mkdtemp, readFile, @@ -157,6 +158,64 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./nested/.env"); }); + test("recovers an embedded checkout hidden only by its own ignore file", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, ".ignore"), "*\n"); + await writeFile(join(nested, "tracked.ts"), "export {};\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain("./nested/tracked.ts"); + await writeFile(join(checkout, ".ignore"), "nested/\n"); + expect(await inventory(checkout)).not.toContain("./nested/tracked.ts"); + }); + + test.each([".ignore", ".rgignore"])( + "does not recover nested tracked files excluded by outer %s rules", + async (ignore) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(checkout, ignore), "nested/private.ts\n"); + await writeFile(join(nested, "private.ts"), "private\n"); + await writeFile(join(nested, "visible.ts"), "visible\n"); + execFileSync("git", ["add", "private.ts", "visible.ts"], { + cwd: nested, + }); + + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); + }, + ); + + test.skipIf(process.platform === "win32")( + "inventories an explicit file without listing its parent directory", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const directory = join(checkout, "restricted"); + await mkdir(directory); + await writeFile(join(directory, "source.ts"), "export {};\n"); + await chmod(directory, 0o111); + try { + expect(await inventory(checkout, "restricted/source.ts")).toEqual([ + "restricted/source.ts", + ]); + } finally { + await chmod(directory, 0o755); + } + }, + ); + test("preserves supported in-repository symbolic path targets", async () => { const checkout = await repository(); const source = join(checkout, "source"); From 9d1a88f69f63abbab681b809aa1db93924fe1d62 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 00:38:58 -0700 Subject: [PATCH 032/106] fix(inventory): preserve configured excludes and nested snapshot paths --- .../scripts/generate_in_scope_files.py | 122 ++++++++++++------ .../tests-ts/scan-inventory.test.ts | 20 ++- 2 files changed, 101 insertions(+), 41 deletions(-) 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 8b91aa68..94e71cf3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -332,34 +332,84 @@ def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: for name in IGNORE_FILE_NAMES if (directory / name).is_file() ] - if not ignore_files: + repository_exclude: bytes | None = None + if (repository / ".git").exists(): + location = run_git( + ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"] + ) + if location.returncode == 0: + exclude = Path(os.fsdecode(location.stdout.rstrip(b"\r\n"))) + if exclude.is_file(): + contents = exclude.read_bytes() + if any( + line.strip() and not line.lstrip().startswith(b"#") + for line in contents.splitlines() + ): + repository_exclude = contents + if not ignore_files and repository_exclude is None: return requested - with tempfile.TemporaryDirectory() as temporary_directory: - probe = Path(temporary_directory) - for ignore in ignore_files: - destination = probe / ignore.relative_to(repository) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(ignore.read_bytes()) - for relative in requested: - destination = probe / os.fsdecode(relative) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.touch() - result = subprocess.run( - [*command, "--", "."], - cwd=probe, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if result.returncode not in (0, 1): - detail = result.stderr.decode("utf-8", errors="replace").strip() - raise InventoryError(f"could not evaluate outer ignore rules: {detail}") - return { - normalized(relative).removeprefix(b"./") - for relative in result.stdout.split(b"\0") - if normalized(relative).removeprefix(b"./") in requested - } + batches: list[tuple[set[str], set[bytes]]] = [] + for relative in requested: + folded = os.fsdecode(relative).casefold() + for names, batch in batches: + if folded not in names: + names.add(folded) + batch.add(relative) + break + else: + batches.append(({folded}, {relative})) + + visible: set[bytes] = set() + for _, batch in batches: + with tempfile.TemporaryDirectory() as temporary_directory: + probe = Path(temporary_directory) + for ignore in ignore_files: + destination = probe / ignore.relative_to(repository) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(ignore.read_bytes()) + if repository_exclude is not None: + exclude = probe / ".git" / "info" / "exclude" + exclude.parent.mkdir(parents=True) + exclude.write_bytes(repository_exclude) + for relative in batch: + destination = probe / os.fsdecode(relative) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.touch() + result = subprocess.run( + [*command, "--", "."], + cwd=probe, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode not in (0, 1): + detail = result.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError(f"could not evaluate outer ignore rules: {detail}") + visible.update( + normalized(relative).removeprefix(b"./") + for relative in result.stdout.split(b"\0") + if normalized(relative).removeprefix(b"./") in batch + ) + return visible + + if not (repository / ".git").exists() and selected.is_dir(): + pending = [selected] + inspected_directories: set[tuple[int, int]] = set() + while pending: + directory = pending.pop() + identity = directory_identity(directory) + if identity in inspected_directories: + continue + inspected_directories.add(identity) + reject_symbolic_ignore(directory) + for entry in directory.iterdir(): + if entry.is_symlink() or not entry.is_dir() or git_metadata_path(directory, entry.name): + continue + if (entry / ".git").exists(): + discovered_roots[directory_identity(entry)] = entry + elif visible_to_outer_ignores(entry, [entry / "scan-source"]): + pending.append(entry) environment = os.environ.copy() for name in ( @@ -475,23 +525,15 @@ def listed_paths(index: int) -> Iterator[bytes]: [relative for relative in result.stdout.split(b"\0") if relative], ) - visible_paths = { - normalized(row.removesuffix(b"\n")).removeprefix(b"./") for row in rows - } outer_tracked_paths = set(listed_paths(0)) def visible_nested_root(root: Path) -> bool: - relative = os.fsencode(root.relative_to(repository).as_posix()) - if selected != repository and (selected == root or selected.is_relative_to(root)): - return True - if any( - path == relative or path.startswith(relative + b"/") - for paths in (visible_paths, outer_tracked_paths) - for path in paths - ): - return True - marker = root / ".codex-security-inventory-probe" - return bool(visible_to_outer_ignores(root, [marker])) + return ( + selected == repository + or selected == root + or selected.is_relative_to(root) + or root.is_relative_to(selected) + ) nested_roots = { identity: root diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 17966989..508713e2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -169,12 +169,14 @@ describe("security scan file inventory", () => { await writeFile(join(nested, "tracked.ts"), "export {};\n"); execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + expect(await inventory(checkout)).toContain("./nested/tracked.ts"); + await writeFile(join(checkout, ".ignore"), ".*\n"); expect(await inventory(checkout)).toContain("./nested/tracked.ts"); await writeFile(join(checkout, ".ignore"), "nested/\n"); expect(await inventory(checkout)).not.toContain("./nested/tracked.ts"); }); - test.each([".ignore", ".rgignore"])( + test.each([".ignore", ".rgignore", ".git/info/exclude"])( "does not recover nested tracked files excluded by outer %s rules", async (ignore) => { if (Bun.which("rg") === null) return; @@ -196,6 +198,22 @@ describe("security scan file inventory", () => { }, ); + test("discovers self-hidden checkouts through visible snapshot directories", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const nested = join(checkout, "container", "nested"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, ".ignore"), "*\n"); + await writeFile(join(nested, "tracked.ts"), "export {};\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain( + "./container/nested/tracked.ts", + ); + }); + test.skipIf(process.platform === "win32")( "inventories an explicit file without listing its parent directory", async () => { From 352f92041f1c69fce5ba9c1e5446d4cf60402ebb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 00:54:32 -0700 Subject: [PATCH 033/106] fix(inventory): honor nested ignore boundaries on every platform --- .../scripts/generate_in_scope_files.py | 146 ++++++++++++------ .../tests-ts/scan-inventory.test.ts | 69 +++++++++ 2 files changed, 170 insertions(+), 45 deletions(-) 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 94e71cf3..9741b168 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -314,7 +314,13 @@ def normalized(path: bytes) -> bytes: if normalized(row.removesuffix(b"\n")).removeprefix(b"./") in visible } - def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: + def visible_to_outer_ignores( + root: Path, + candidates: list[Path], + *, + directories_only: bool = False, + include_gitignore: bool = True, + ) -> set[bytes]: requested = { normalized(os.fsencode(candidate.relative_to(repository).as_posix())) for candidate in candidates @@ -330,12 +336,16 @@ def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: directory / name for directory in directories for name in IGNORE_FILE_NAMES + if include_gitignore or name != ".gitignore" if (directory / name).is_file() ] - repository_exclude: bytes | None = None - if (repository / ".git").exists(): + configured_excludes: dict[Path, bytes] = {} + for directory in directories: + if not (directory / ".git").exists(): + continue location = run_git( - ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"] + ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], + directory=directory, ) if location.returncode == 0: exclude = Path(os.fsdecode(location.stdout.rstrip(b"\r\n"))) @@ -345,20 +355,24 @@ def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: line.strip() and not line.lstrip().startswith(b"#") for line in contents.splitlines() ): - repository_exclude = contents - if not ignore_files and repository_exclude is None: + configured_excludes[directory] = contents + if not ignore_files and not configured_excludes: return requested - batches: list[tuple[set[str], set[bytes]]] = [] + batches: list[tuple[dict[str, str], set[bytes]]] = [] for relative in requested: - folded = os.fsdecode(relative).casefold() + parts = PurePosixPath(os.fsdecode(relative)).parts + prefixes = { + "/".join(parts[: index + 1]).casefold(): "/".join(parts[: index + 1]) + for index in range(len(parts)) + } for names, batch in batches: - if folded not in names: - names.add(folded) + if all(names.get(folded, spelling) == spelling for folded, spelling in prefixes.items()): + names.update(prefixes) batch.add(relative) break else: - batches.append(({folded}, {relative})) + batches.append((prefixes, {relative})) visible: set[bytes] = set() for _, batch in batches: @@ -368,16 +382,19 @@ def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: destination = probe / ignore.relative_to(repository) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(ignore.read_bytes()) - if repository_exclude is not None: - exclude = probe / ".git" / "info" / "exclude" - exclude.parent.mkdir(parents=True) - exclude.write_bytes(repository_exclude) + for directory, contents in configured_excludes.items(): + exclude = probe / directory.relative_to(repository) / ".git" / "info" / "exclude" + exclude.parent.mkdir(parents=True, exist_ok=True) + exclude.write_bytes(contents) for relative in batch: destination = probe / os.fsdecode(relative) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.touch() + if directories_only: + destination.mkdir(parents=True, exist_ok=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.touch() result = subprocess.run( - [*command, "--", "."], + [*command, *(["--debug"] if directories_only else []), "--", "."], cwd=probe, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -386,31 +403,28 @@ def visible_to_outer_ignores(root: Path, candidates: list[Path]) -> set[bytes]: if result.returncode not in (0, 1): detail = result.stderr.decode("utf-8", errors="replace").strip() raise InventoryError(f"could not evaluate outer ignore rules: {detail}") - visible.update( - normalized(relative).removeprefix(b"./") - for relative in result.stdout.split(b"\0") - if normalized(relative).removeprefix(b"./") in batch - ) + if directories_only: + ignored = { + normalized(match.group(1)).removeprefix(b"./") + for line in result.stderr.splitlines() + if (match := re.search(rb": ignoring (.+): Ignore\(", line)) is not None + } + visible.update( + relative + for relative in batch + if not any( + relative == excluded or relative.startswith(excluded + b"/") + for excluded in ignored + ) + ) + else: + visible.update( + normalized(relative).removeprefix(b"./") + for relative in result.stdout.split(b"\0") + if normalized(relative).removeprefix(b"./") in batch + ) return visible - if not (repository / ".git").exists() and selected.is_dir(): - pending = [selected] - inspected_directories: set[tuple[int, int]] = set() - while pending: - directory = pending.pop() - identity = directory_identity(directory) - if identity in inspected_directories: - continue - inspected_directories.add(identity) - reject_symbolic_ignore(directory) - for entry in directory.iterdir(): - if entry.is_symlink() or not entry.is_dir() or git_metadata_path(directory, entry.name): - continue - if (entry / ".git").exists(): - discovered_roots[directory_identity(entry)] = entry - elif visible_to_outer_ignores(entry, [entry / "scan-source"]): - pending.append(entry) - environment = os.environ.copy() for name in ( "GIT_ALTERNATE_OBJECT_DIRECTORIES", @@ -493,6 +507,37 @@ def resolve_git_root(value: bytes) -> Path: if worktree_root != repository: worktree = None + if worktree is None and selected.is_dir(): + pending = [selected] + inspected_directories: set[tuple[int, int]] = set() + while pending: + directory = pending.pop() + identity = directory_identity(directory) + if identity in inspected_directories: + continue + inspected_directories.add(identity) + reject_symbolic_ignore(directory) + children = [ + entry + for entry in directory.iterdir() + if not entry.is_symlink() + and entry.is_dir() + and not git_metadata_path(directory, entry.name) + ] + for entry in children: + if (entry / ".git").exists(): + discovered_roots[directory_identity(entry)] = entry + ordinary = [entry for entry in children if not (entry / ".git").exists()] + if ordinary: + visible = visible_to_outer_ignores( + ordinary[0], ordinary, directories_only=True + ) + pending.extend( + entry + for entry in ordinary + if normalized(os.fsencode(entry.relative_to(repository).as_posix())) in visible + ) + if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" listed: list[list[bytes]] = [[], []] @@ -816,15 +861,26 @@ def descend(parent: Path, index: int) -> list[Path]: ) else None ) + gitlink_candidates = [ + candidate + for candidate in candidates + if any( + (relative := os.fsencode(candidate.relative_to(repository).as_posix())) == tracked + or relative.startswith(tracked + b"/") + for tracked in outer_tracked_paths + ) + ] + gitlink_visible = ( + visible_to_outer_ignores(root, gitlink_candidates, include_gitignore=False) + if outer_visible is not None and gitlink_candidates + else set() + ) for candidate in candidates: relative = os.fsencode(candidate.relative_to(repository).as_posix()) if ( outer_visible is not None and normalized(relative) not in outer_visible - and not any( - relative == tracked or relative.startswith(tracked + b"/") - for tracked in outer_tracked_paths - ) + and normalized(relative) not in gitlink_visible ): continue relative_path = prefix + relative diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 508713e2..24ec7ac8 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -198,12 +198,71 @@ describe("security scan file inventory", () => { }, ); + test("applies file exclusions beneath tracked Git checkouts", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "nested/\n"), + writeFile(join(checkout, ".ignore"), "nested/private.ts\n"), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track nested source", + ], + { cwd: nested }, + ); + execFileSync("git", ["add", "--force", "nested"], { + cwd: checkout, + stdio: "ignore", + }); + + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); + }); + + test("applies configured excludes from every enclosing Git checkout", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const middle = join(checkout, "middle"); + const nested = join(middle, "nested"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: middle }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(middle, ".git", "info", "exclude"), "nested/private.ts\n"), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + + const rows = await inventory(checkout); + expect(rows).toContain("./middle/nested/visible.ts"); + expect(rows).not.toContain("./middle/nested/private.ts"); + }); + test("discovers self-hidden checkouts through visible snapshot directories", async () => { if (Bun.which("rg") === null) return; const checkout = await repository(false); const nested = join(checkout, "container", "nested"); await mkdir(nested, { recursive: true }); + await writeFile(join(checkout, ".ignore"), "scan-source\n"); execFileSync("git", ["init", "-q"], { cwd: nested }); await writeFile(join(nested, ".ignore"), "*\n"); await writeFile(join(nested, "tracked.ts"), "export {};\n"); @@ -212,6 +271,16 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain( "./container/nested/tracked.ts", ); + + await writeFile(join(checkout, ".ignore"), "scan-source\ncontainer/\n"); + expect(await inventory(checkout)).not.toContain( + "./container/nested/tracked.ts", + ); + await writeFile(join(checkout, ".ignore"), "scan-source\n"); + await writeFile(join(checkout, ".git"), "malformed snapshot marker\n"); + expect(await inventory(checkout)).toContain( + "./container/nested/tracked.ts", + ); }); test.skipIf(process.platform === "win32")( From 8648f0ef72970f1312d417362bd893eb7457f5db Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 01:09:15 -0700 Subject: [PATCH 034/106] fix(inventory): scope gitlink ignore exemptions to owning worktrees --- .../scripts/generate_in_scope_files.py | 99 ++++++++++++------- .../tests-ts/scan-inventory.test.ts | 84 ++++++++++++++++ 2 files changed, 150 insertions(+), 33 deletions(-) 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 9741b168..73270661 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -319,7 +319,7 @@ def visible_to_outer_ignores( candidates: list[Path], *, directories_only: bool = False, - include_gitignore: bool = True, + exempt_gitignores: tuple[tuple[Path, Path], ...] = (), ) -> set[bytes]: requested = { normalized(os.fsencode(candidate.relative_to(repository).as_posix())) @@ -336,7 +336,13 @@ def visible_to_outer_ignores( directory / name for directory in directories for name in IGNORE_FILE_NAMES - if include_gitignore or name != ".gitignore" + if name != ".gitignore" + or not any( + directory.is_relative_to(owner) + and gitlink.is_relative_to(directory) + and directory != gitlink + for owner, gitlink in exempt_gitignores + ) if (directory / name).is_file() ] configured_excludes: dict[Path, bytes] = {} @@ -383,9 +389,11 @@ def visible_to_outer_ignores( destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(ignore.read_bytes()) for directory, contents in configured_excludes.items(): - exclude = probe / directory.relative_to(repository) / ".git" / "info" / "exclude" + exclude = probe / directory.relative_to(repository) / ".gitignore" exclude.parent.mkdir(parents=True, exist_ok=True) - exclude.write_bytes(contents) + existing = exclude.read_bytes() if exclude.exists() else b"" + separator = b"" if not contents or contents.endswith(b"\n") else b"\n" + exclude.write_bytes(contents + separator + existing) for relative in batch: destination = probe / os.fsdecode(relative) if directories_only: @@ -517,17 +525,31 @@ def resolve_git_root(value: bytes) -> Path: continue inspected_directories.add(identity) reject_symbolic_ignore(directory) - children = [ - entry - for entry in directory.iterdir() - if not entry.is_symlink() - and entry.is_dir() - and not git_metadata_path(directory, entry.name) - ] + children = [] + for entry in directory.iterdir(): + try: + metadata = entry.stat(follow_symlinks=False) + except OSError: + continue + if ( + symbolic_metadata(metadata) + or not stat.S_ISDIR(metadata.st_mode) + or git_metadata_path(directory, entry.name) + ): + continue + children.append(entry) + valid_roots = set() for entry in children: if (entry / ".git").exists(): - discovered_roots[directory_identity(entry)] = entry - ordinary = [entry for entry in children if not (entry / ".git").exists()] + candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) + if candidate.returncode == 0: + try: + if resolve_git_root(candidate.stdout) == entry: + discovered_roots[directory_identity(entry)] = entry + valid_roots.add(entry) + except (OSError, ValueError): + pass + ordinary = [entry for entry in children if entry not in valid_roots] if ordinary: visible = visible_to_outer_ignores( ordinary[0], ordinary, directories_only=True @@ -570,8 +592,6 @@ def listed_paths(index: int) -> Iterator[bytes]: [relative for relative in result.stdout.split(b"\0") if relative], ) - outer_tracked_paths = set(listed_paths(0)) - def visible_nested_root(root: Path) -> bool: return ( selected == repository @@ -763,6 +783,16 @@ def visible_nested_root(root: Path) -> bool: os.fsencode(part) for part in selected.relative_to(repository).parts ) selected_is_directory = selected.is_dir() + tracked_gitlinks = [] + for owner, tracked_paths in cached_by_root.values(): + indexed_paths = set(tracked_paths) + tracked_gitlinks.extend( + (owner, nested) + for nested in inspected_roots.values() + if nested != owner + and nested.is_relative_to(owner) + and os.fsencode(nested.relative_to(owner).as_posix()) in indexed_paths + ) def tracked_variants( root_identity: tuple[int, int], root: Path, relative: bytes @@ -854,27 +884,30 @@ def descend(parent: Path, index: int) -> list[Path]: ] outer_visible = ( visible_to_outer_ignores(root, candidates) - if root != repository - and not ( - selected != repository - and (selected == root or selected.is_relative_to(root)) - ) + if root != repository and selected_is_directory else None ) - gitlink_candidates = [ - candidate - for candidate in candidates - if any( - (relative := os.fsencode(candidate.relative_to(repository).as_posix())) == tracked - or relative.startswith(tracked + b"/") - for tracked in outer_tracked_paths + gitlink_groups: dict[tuple[tuple[Path, Path], ...], list[Path]] = {} + if outer_visible is not None: + for candidate in candidates: + exemptions = tuple( + (owner, gitlink) + for owner, gitlink in tracked_gitlinks + if candidate.is_relative_to(gitlink) + ) + if selected != repository and ( + selected == root or selected.is_relative_to(root) + ): + exemptions += ((repository, selected),) + if exemptions: + gitlink_groups.setdefault(exemptions, []).append(candidate) + gitlink_visible = { + relative + for exemptions, linked_candidates in gitlink_groups.items() + for relative in visible_to_outer_ignores( + root, linked_candidates, exempt_gitignores=exemptions ) - ] - gitlink_visible = ( - visible_to_outer_ignores(root, gitlink_candidates, include_gitignore=False) - if outer_visible is not None and gitlink_candidates - else set() - ) + } for candidate in candidates: relative = os.fsencode(candidate.relative_to(repository).as_posix()) if ( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 24ec7ac8..f93df5f1 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -233,6 +233,10 @@ describe("security scan file inventory", () => { const rows = await inventory(checkout); expect(rows).toContain("./nested/visible.ts"); expect(rows).not.toContain("./nested/private.ts"); + + const scoped = await inventory(checkout, "nested"); + expect(scoped).toContain("nested/visible.ts"); + expect(scoped).not.toContain("nested/private.ts"); }); test("applies configured excludes from every enclosing Git checkout", async () => { @@ -256,6 +260,46 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); + test("preserves intermediate ignores beneath an ancestor Git link", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const middle = join(checkout, "middle"); + const nested = join(middle, "nested"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: middle }); + await writeFile(join(middle, "visible.ts"), "visible\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: middle }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track intermediate source", + ], + { cwd: middle }, + ); + execFileSync("git", ["add", "middle"], { + cwd: checkout, + stdio: "ignore", + }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(middle, ".gitignore"), "nested/private.ts\n"), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + + const rows = await inventory(checkout); + expect(rows).toContain("./middle/nested/visible.ts"); + expect(rows).not.toContain("./middle/nested/private.ts"); + }); + test("discovers self-hidden checkouts through visible snapshot directories", async () => { if (Bun.which("rg") === null) return; @@ -272,6 +316,14 @@ describe("security scan file inventory", () => { "./container/nested/tracked.ts", ); + await writeFile( + join(checkout, "container", ".git"), + "malformed nested Git marker\n", + ); + expect(await inventory(checkout)).toContain( + "./container/nested/tracked.ts", + ); + await writeFile(join(checkout, ".ignore"), "scan-source\ncontainer/\n"); expect(await inventory(checkout)).not.toContain( "./container/nested/tracked.ts", @@ -283,6 +335,38 @@ describe("security scan file inventory", () => { ); }); + test("keeps tracked files when an ignored snapshot checkout is explicitly selected", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const nested = join(checkout, "ignored"); + await mkdir(nested); + await writeFile(join(checkout, ".gitignore"), "ignored/\n"); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, "public.ts"), "tracked\n"), + writeFile(join(nested, "private.ts"), "ignored\n"), + ]); + execFileSync("git", ["add", "public.ts"], { cwd: nested }); + + expect(await inventory(checkout, "ignored")).toEqual(["ignored/public.ts"]); + }); + + test.skipIf(process.platform !== "win32")( + "does not traverse external Windows directory junctions", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const external = join(dirname(checkout), "outside"); + await mkdir(join(external, ".ignore"), { recursive: true }); + await symlink(external, join(checkout, "junction"), "junction"); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toEqual(["./visible.ts"]); + }, + ); + test.skipIf(process.platform === "win32")( "inventories an explicit file without listing its parent directory", async () => { From 74fc9f4ca827130bdc9e3d2053e9af7f1a8b38a4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 01:28:28 -0700 Subject: [PATCH 035/106] fix(inventory): bind checkout exemptions to real filesystem entries --- .../scripts/generate_in_scope_files.py | 88 +++++++++++++------ .../tests-ts/scan-inventory.test.ts | 39 ++++++++ 2 files changed, 100 insertions(+), 27 deletions(-) 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 73270661..cd505b18 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -158,6 +158,13 @@ def directory_identity(path: Path) -> tuple[int, int]: metadata = path.stat() return metadata.st_dev, metadata.st_ino + def nonsymbolic_directory(path: Path) -> bool: + try: + metadata = path.stat(follow_symlinks=False) + except OSError: + return False + return stat.S_ISDIR(metadata.st_mode) and not symbolic_metadata(metadata) + discovered_roots: dict[tuple[int, int], Path] = {} metadata_aliases: set[tuple[str, ...]] = set() for ancestor in ancestors: @@ -279,8 +286,7 @@ def normalized(path: bytes) -> bytes: metadata_aliases.add(entry.relative_to(repository).parts) elif ( entry.name != ".git" - and not entry.is_symlink() - and entry.is_dir() + and nonsymbolic_directory(entry) and (entry / ".git").exists() ): discovered_roots[directory_identity(entry)] = entry @@ -525,31 +531,24 @@ def resolve_git_root(value: bytes) -> Path: continue inspected_directories.add(identity) reject_symbolic_ignore(directory) - children = [] - for entry in directory.iterdir(): - try: - metadata = entry.stat(follow_symlinks=False) - except OSError: - continue - if ( - symbolic_metadata(metadata) - or not stat.S_ISDIR(metadata.st_mode) - or git_metadata_path(directory, entry.name) - ): - continue - children.append(entry) - valid_roots = set() + children = [ + entry + for entry in directory.iterdir() + if nonsymbolic_directory(entry) and not git_metadata_path(directory, entry.name) + ] + valid_roots: set[tuple[int, int]] = set() for entry in children: if (entry / ".git").exists(): candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) if candidate.returncode == 0: try: if resolve_git_root(candidate.stdout) == entry: - discovered_roots[directory_identity(entry)] = entry - valid_roots.add(entry) + identity = directory_identity(entry) + discovered_roots[identity] = entry + valid_roots.add(identity) except (OSError, ValueError): pass - ordinary = [entry for entry in children if entry not in valid_roots] + ordinary = [entry for entry in children if directory_identity(entry) not in valid_roots] if ordinary: visible = visible_to_outer_ignores( ordinary[0], ordinary, directories_only=True @@ -783,14 +782,34 @@ def visible_nested_root(root: Path) -> bool: os.fsencode(part) for part in selected.relative_to(repository).parts ) selected_is_directory = selected.is_dir() + + def exact_descendant(candidate: Path, parent: Path) -> bool: + candidate_parts = candidate.relative_to(repository).parts + parent_parts = parent.relative_to(repository).parts + return candidate_parts[: len(parent_parts)] == parent_parts + tracked_gitlinks = [] - for owner, tracked_paths in cached_by_root.values(): - indexed_paths = set(tracked_paths) + for owner, _tracked_paths in cached_by_root.values(): + staged = run_git(["ls-files", "--stage", "-z"], directory=owner) + if staged.returncode: + detail = staged.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError(f"git ls-files --stage exited with status {staged.returncode}: {detail}") + indexed_paths = { + path + for record in staged.stdout.split(b"\0") + if record + and (parts := record.partition(b"\t"))[1] + and (header := parts[0].split()) + and len(header) == 3 + and header[0] == b"160000" + and header[2] == b"0" + for path in (parts[2],) + } tracked_gitlinks.extend( (owner, nested) for nested in inspected_roots.values() if nested != owner - and nested.is_relative_to(owner) + and exact_descendant(nested, owner) and os.fsencode(nested.relative_to(owner).as_posix()) in indexed_paths ) @@ -889,16 +908,31 @@ def descend(parent: Path, index: int) -> list[Path]: ) gitlink_groups: dict[tuple[tuple[Path, Path], ...], list[Path]] = {} if outer_visible is not None: + scope_exemptions: tuple[tuple[Path, Path], ...] = () + if selected != repository and ( + selected == root or exact_descendant(selected, root) + ): + selected_path = normalized( + os.fsencode(selected.relative_to(repository).as_posix()) + ) + selected_visible = visible_to_outer_ignores( + selected, [selected], directories_only=True + ) + candidate_exemption = ((repository, selected),) + if selected_path not in selected_visible and selected_path in visible_to_outer_ignores( + selected, + [selected], + directories_only=True, + exempt_gitignores=candidate_exemption, + ): + scope_exemptions = candidate_exemption for candidate in candidates: exemptions = tuple( (owner, gitlink) for owner, gitlink in tracked_gitlinks - if candidate.is_relative_to(gitlink) + if exact_descendant(candidate, gitlink) ) - if selected != repository and ( - selected == root or selected.is_relative_to(root) - ): - exemptions += ((repository, selected),) + exemptions += scope_exemptions if exemptions: gitlink_groups.setdefault(exemptions, []).append(candidate) gitlink_visible = { diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index f93df5f1..ce086a4f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -300,6 +300,45 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); + test("preserves outer Git file exclusions for explicit nested scopes", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "nested/private.ts\n"), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + + expect(await inventory(checkout, "nested")).toEqual(["nested/visible.ts"]); + }); + + test("does not grant Git link exemptions to replaced tracked files", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await writeFile(nested, "previously tracked file\n"); + execFileSync("git", ["add", "nested"], { cwd: checkout }); + await rm(nested); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "nested/private.ts\n"), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); + }); + test("discovers self-hidden checkouts through visible snapshot directories", async () => { if (Bun.which("rg") === null) return; From ca70229e9d842d301d31f91e9194a246158ecf2f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 01:48:09 -0700 Subject: [PATCH 036/106] fix(inventory): preserve ignore boundaries across checkout discovery --- .../scripts/generate_in_scope_files.py | 146 +++++++++++-- .../tests-ts/scan-inventory.test.ts | 191 ++++++++++++------ 2 files changed, 258 insertions(+), 79 deletions(-) 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 cd505b18..f65f7412 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -326,6 +326,7 @@ def visible_to_outer_ignores( *, directories_only: bool = False, exempt_gitignores: tuple[tuple[Path, Path], ...] = (), + preserve_gitignore_descendants: bool = False, ) -> set[bytes]: requested = { normalized(os.fsencode(candidate.relative_to(repository).as_posix())) @@ -343,6 +344,7 @@ def visible_to_outer_ignores( for directory in directories for name in IGNORE_FILE_NAMES if name != ".gitignore" + or preserve_gitignore_descendants or not any( directory.is_relative_to(owner) and gitlink.is_relative_to(directory) @@ -389,17 +391,94 @@ def visible_to_outer_ignores( visible: set[bytes] = set() for _, batch in batches: with tempfile.TemporaryDirectory() as temporary_directory: - probe = Path(temporary_directory) - for ignore in ignore_files: - destination = probe / ignore.relative_to(repository) + temporary_root = Path(temporary_directory) + probe = temporary_root / "inventory" + probe.mkdir() + external_ignores: list[Path] = [] + + def install_ignore( + directory: Path, + name: str, + contents: bytes, + *, + prepend: bool = False, + ) -> None: + relative = (*directory.relative_to(repository).parts, name) + + def collides_with(candidate: bytes) -> bool: + pairs = tuple( + zip(PurePosixPath(os.fsdecode(candidate)).parts, relative) + ) + return all( + actual.casefold() == synthetic.casefold() + for actual, synthetic in pairs + ) and any(actual != synthetic for actual, synthetic in pairs) + + collides = any(collides_with(candidate) for candidate in batch) + if collides: + if directory != repository: + prefix = os.fsencode(directory.relative_to(repository).as_posix()) + rebased = [] + for line in contents.splitlines(): + if not line or line.startswith(b"#"): + continue + negated = line.startswith(b"!") + pattern = line[1:] if negated else line + if pattern.startswith(b"/"): + pattern = pattern[1:] + elif b"/" not in pattern.rstrip(b"/"): + pattern = b"**/" + pattern + rebased.append( + (b"!" if negated else b"") + + b"/" + + prefix + + b"/" + + pattern + + b"\n" + ) + contents = b"".join(rebased) + destination = temporary_root / f"ignore-{len(external_ignores)}" + destination.write_bytes(contents) + external_ignores.append(destination) + return + + destination = probe.joinpath(*relative) destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(ignore.read_bytes()) + if prepend and destination.exists(): + existing = destination.read_bytes() + separator = b"" if not contents or contents.endswith(b"\n") else b"\n" + contents += separator + existing + destination.write_bytes(contents) + + for ignore in ignore_files: + contents = ignore.read_bytes() + if preserve_gitignore_descendants and ignore.name == ".gitignore": + for owner, selected_root in exempt_gitignores: + directory = ignore.parent + if not ( + directory.is_relative_to(owner) + and selected_root.is_relative_to(directory) + and directory != selected_root + ): + continue + parts = selected_root.relative_to(directory).parts + for index in range(len(parts)): + if contents and not contents.endswith(b"\n"): + contents += b"\n" + admitted = "/".join( + re.escape(part) for part in parts[: index + 1] + ) + contents += os.fsencode(f"!/{admitted}/\n") + install_ignore(ignore.parent, ignore.name, contents) for directory, contents in configured_excludes.items(): - exclude = probe / directory.relative_to(repository) / ".gitignore" - exclude.parent.mkdir(parents=True, exist_ok=True) - existing = exclude.read_bytes() if exclude.exists() else b"" - separator = b"" if not contents or contents.endswith(b"\n") else b"\n" - exclude.write_bytes(contents + separator + existing) + protects_exemption = preserve_gitignore_descendants and any( + directory.is_relative_to(owner) + and selected_root.is_relative_to(directory) + and directory != selected_root + for owner, selected_root in exempt_gitignores + ) + ignore_name = ".ignore" if protects_exemption else ".gitignore" + install_ignore(directory, ignore_name, contents, prepend=True) for relative in batch: destination = probe / os.fsdecode(relative) if directories_only: @@ -408,7 +487,17 @@ def visible_to_outer_ignores( destination.parent.mkdir(parents=True, exist_ok=True) destination.touch() result = subprocess.run( - [*command, *(["--debug"] if directories_only else []), "--", "."], + [ + *command, + *( + argument + for ignore in external_ignores + for argument in ("--ignore-file", str(ignore)) + ), + *(["--debug"] if directories_only else []), + "--", + ".", + ], cwd=probe, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -491,6 +580,12 @@ def resolve_git_root(value: bytes) -> Path: root_path = root_path.removesuffix(b"\r") return Path(os.fsdecode(root_path)).resolve(strict=True) + def owns_git_root(value: bytes, expected: Path) -> bool: + actual = resolve_git_root(value) + return directory_identity(actual) == directory_identity(expected) and ( + actual.relative_to(repository).parts == expected.relative_to(repository).parts + ) + worktree = ( run_git(["rev-parse", "--show-toplevel"]) if (repository / ".git").exists() @@ -515,13 +610,21 @@ def resolve_git_root(value: bytes) -> Path: if worktree is not None: try: - worktree_root = resolve_git_root(worktree.stdout) + valid_worktree = owns_git_root(worktree.stdout, repository) except (OSError, ValueError) as error: raise InventoryError(f"could not resolve Git worktree root: {error}") from error - if worktree_root != repository: + if not valid_worktree: worktree = None - if worktree is None and selected.is_dir(): + ripgrep_overrides = any( + PurePosixPath(os.fsdecode(row.removesuffix(b"\n"))).name in (".ignore", ".rgignore") + for row in rows + ) or any( + (ancestor / name).is_file() + for ancestor in ancestors + for name in (".ignore", ".rgignore") + ) + if selected.is_dir() and (worktree is None or ripgrep_overrides): pending = [selected] inspected_directories: set[tuple[int, int]] = set() while pending: @@ -542,7 +645,7 @@ def resolve_git_root(value: bytes) -> Path: candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) if candidate.returncode == 0: try: - if resolve_git_root(candidate.stdout) == entry: + if owns_git_root(candidate.stdout, entry): identity = directory_identity(entry) discovered_roots[identity] = entry valid_roots.add(identity) @@ -612,9 +715,9 @@ def visible_nested_root(root: Path) -> bool: for index in range(len(listed)): for relative in listed_paths(index): candidate = repository / os.fsdecode(relative) - current = candidate if candidate.is_dir() else candidate.parent + current = candidate if nonsymbolic_directory(candidate) else candidate.parent while current != repository: - if not current.is_symlink() and (current / ".git").exists(): + if nonsymbolic_directory(current) and (current / ".git").exists(): try: discovered = current.resolve(strict=True) discovered.relative_to(repository) @@ -649,7 +752,7 @@ def visible_nested_root(root: Path) -> bool: f"nested git rev-parse exited with status {nested_worktree.returncode}: {detail}" ) try: - if resolve_git_root(nested_worktree.stdout) != nested: + if not owns_git_root(nested_worktree.stdout, nested): continue except (OSError, ValueError): continue @@ -687,7 +790,7 @@ def visible_nested_root(root: Path) -> bool: if not relative: continue candidate = nested / os.fsdecode(relative) - if candidate.is_symlink() or not candidate.is_dir(): + if not nonsymbolic_directory(candidate): continue if not (candidate / ".git").exists(): continue @@ -907,8 +1010,8 @@ def descend(parent: Path, index: int) -> list[Path]: else None ) gitlink_groups: dict[tuple[tuple[Path, Path], ...], list[Path]] = {} + scope_exemptions: tuple[tuple[Path, Path], ...] = () if outer_visible is not None: - scope_exemptions: tuple[tuple[Path, Path], ...] = () if selected != repository and ( selected == root or exact_descendant(selected, root) ): @@ -939,7 +1042,10 @@ def descend(parent: Path, index: int) -> list[Path]: relative for exemptions, linked_candidates in gitlink_groups.items() for relative in visible_to_outer_ignores( - root, linked_candidates, exempt_gitignores=exemptions + root, + linked_candidates, + exempt_gitignores=exemptions, + preserve_gitignore_descendants=True, ) } for candidate in candidates: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ce086a4f..fb1fa092 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -198,46 +198,56 @@ describe("security scan file inventory", () => { }, ); - test("applies file exclusions beneath tracked Git checkouts", async () => { - if (Bun.which("rg") === null) return; + test.each([".ignore", ".gitignore", ".git/info/exclude"])( + "applies %s file exclusions beneath tracked Git checkouts", + async (outerIgnore) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const nested = join(checkout, "nested"); - await mkdir(nested); - execFileSync("git", ["init", "-q"], { cwd: nested }); - await Promise.all([ - writeFile(join(checkout, ".gitignore"), "nested/\n"), - writeFile(join(checkout, ".ignore"), "nested/private.ts\n"), - writeFile(join(nested, "private.ts"), "private\n"), - writeFile(join(nested, "visible.ts"), "visible\n"), - ]); - execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); - execFileSync( - "git", - [ - "-c", - "user.name=Inventory Test", - "-c", - "user.email=inventory@example.test", - "commit", - "-qm", - "Track nested source", - ], - { cwd: nested }, - ); - execFileSync("git", ["add", "--force", "nested"], { - cwd: checkout, - stdio: "ignore", - }); + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile( + join(checkout, ".gitignore"), + outerIgnore === ".gitignore" + ? "nested/\nnested/private.ts\n" + : "nested/\n", + ), + ...(outerIgnore === ".gitignore" + ? [] + : [writeFile(join(checkout, outerIgnore), "nested/private.ts\n")]), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track nested source", + ], + { cwd: nested }, + ); + execFileSync("git", ["add", "--force", "nested"], { + cwd: checkout, + stdio: "ignore", + }); - const rows = await inventory(checkout); - expect(rows).toContain("./nested/visible.ts"); - expect(rows).not.toContain("./nested/private.ts"); + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); - const scoped = await inventory(checkout, "nested"); - expect(scoped).toContain("nested/visible.ts"); - expect(scoped).not.toContain("nested/private.ts"); - }); + const scoped = await inventory(checkout, "nested"); + expect(scoped).toContain("nested/visible.ts"); + expect(scoped).not.toContain("nested/private.ts"); + }, + ); test("applies configured excludes from every enclosing Git checkout", async () => { if (Bun.which("rg") === null) return; @@ -300,22 +310,33 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); - test("preserves outer Git file exclusions for explicit nested scopes", async () => { - if (Bun.which("rg") === null) return; - - const checkout = await repository(); - const nested = join(checkout, "nested"); - await mkdir(nested); - execFileSync("git", ["init", "-q"], { cwd: nested }); - await Promise.all([ - writeFile(join(checkout, ".gitignore"), "nested/private.ts\n"), - writeFile(join(nested, "private.ts"), "private\n"), - writeFile(join(nested, "visible.ts"), "visible\n"), - ]); - execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + test.each([ + ["visible", "nested/private.ts\n"], + ["ignored", "nested/\nnested/private.ts\n"], + ])( + "preserves outer Git file exclusions for %s explicit nested scopes", + async (_visibility, outerIgnores) => { + if (Bun.which("rg") === null) return; - expect(await inventory(checkout, "nested")).toEqual(["nested/visible.ts"]); - }); + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), outerIgnores), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + + expect(await inventory(checkout, "nested")).toEqual([ + "nested/visible.ts", + ]); + + await writeFile(join(checkout, ".git", "info", "exclude"), "nested/\n"); + expect(await inventory(checkout, "nested")).toEqual([]); + }, + ); test("does not grant Git link exemptions to replaced tracked files", async () => { if (Bun.which("rg") === null) return; @@ -374,6 +395,50 @@ describe("security scan file inventory", () => { ); }); + test("discovers Git-hidden checkouts reopened by ripgrep ignore rules", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "container", "nested"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "container/\n"), + writeFile(join(checkout, ".ignore"), "!container/\n!container/nested/\n"), + writeFile(join(nested, ".ignore"), "*\n"), + writeFile(join(nested, "tracked.ts"), "export {};\n"), + ]); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain( + "./container/nested/tracked.ts", + ); + }); + + test("keeps ignore scaffolding separate from case-distinct checkouts", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await writeFile(join(checkout, ".ignore"), ".IGNORE/private.ts\n"); + const nested = join(checkout, ".IGNORE"); + try { + await mkdir(nested); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, "tracked.ts"), "visible\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + execFileSync("git", ["add", "tracked.ts", "private.ts"], { cwd: nested }); + + const rows = await inventory(checkout); + expect(rows).toContain("./.IGNORE/tracked.ts"); + expect(rows).not.toContain("./.IGNORE/private.ts"); + }); + test("keeps tracked files when an ignored snapshot checkout is explicitly selected", async () => { if (Bun.which("rg") === null) return; @@ -396,13 +461,21 @@ describe("security scan file inventory", () => { async () => { if (Bun.which("rg") === null) return; - const checkout = await repository(false); - const external = join(dirname(checkout), "outside"); - await mkdir(join(external, ".ignore"), { recursive: true }); - await symlink(external, join(checkout, "junction"), "junction"); - await writeFile(join(checkout, "visible.ts"), "visible\n"); - - expect(await inventory(checkout)).toEqual(["./visible.ts"]); + for (const initializeGit of [false, true]) { + const checkout = await repository(initializeGit); + const external = join(dirname(checkout), "outside"); + await mkdir(join(external, ".ignore"), { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: external }); + if (initializeGit) { + await writeFile(join(checkout, "junction"), "tracked\n"); + execFileSync("git", ["add", "junction"], { cwd: checkout }); + await rm(join(checkout, "junction")); + } + await symlink(external, join(checkout, "junction"), "junction"); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toEqual(["./visible.ts"]); + } }, ); From c492625d7372c46943ae1081179225a2fa24eb85 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 02:08:20 -0700 Subject: [PATCH 037/106] fix(inventory): preserve native ignore precedence and safe Git metadata --- .../scripts/generate_in_scope_files.py | 94 ++++++++++++------- .../tests-ts/scan-inventory.test.ts | 76 ++++++++++++++- 2 files changed, 135 insertions(+), 35 deletions(-) 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 f65f7412..0fb5d278 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -165,10 +165,22 @@ def nonsymbolic_directory(path: Path) -> bool: return False return stat.S_ISDIR(metadata.st_mode) and not symbolic_metadata(metadata) + def has_git_marker(directory: Path) -> bool: + try: + metadata = (directory / ".git").stat(follow_symlinks=False) + except FileNotFoundError: + return False + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if symbolic_metadata(metadata): + raise InventoryError("symbolic Git metadata paths are not supported") + return stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode) + discovered_roots: dict[tuple[int, int], Path] = {} metadata_aliases: set[tuple[str, ...]] = set() for ancestor in ancestors: reject_symbolic_ignore(ancestor) + has_git_marker(ancestor) command = [ "rg", @@ -277,7 +289,7 @@ def normalized(path: bytes) -> bytes: current = current.parent for directory in sorted(visible_directories): reject_symbolic_ignore(directory, allow_ignored=True) - if directory != repository and (directory / ".git").exists(): + if directory != repository and has_git_marker(directory): discovered_roots[directory_identity(directory)] = directory if not selected.is_dir(): continue @@ -287,7 +299,7 @@ def normalized(path: bytes) -> bytes: elif ( entry.name != ".git" and nonsymbolic_directory(entry) - and (entry / ".git").exists() + and has_git_marker(entry) ): discovered_roots[directory_identity(entry)] = entry if metadata_aliases: @@ -355,7 +367,7 @@ def visible_to_outer_ignores( ] configured_excludes: dict[Path, bytes] = {} for directory in directories: - if not (directory / ".git").exists(): + if not has_git_marker(directory): continue location = run_git( ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], @@ -394,7 +406,32 @@ def visible_to_outer_ignores( temporary_root = Path(temporary_directory) probe = temporary_root / "inventory" probe.mkdir() - external_ignores: list[Path] = [] + external_ignores: list[tuple[int, int, int, Path]] = [] + + def collides_with_candidates(relative: tuple[str, ...]) -> bool: + for candidate in batch: + pairs = tuple( + zip(PurePosixPath(os.fsdecode(candidate)).parts, relative) + ) + if all( + actual.casefold() == synthetic.casefold() + for actual, synthetic in pairs + ) and any(actual != synthetic for actual, synthetic in pairs): + return True + return False + + isolate_ignores = any( + collides_with_candidates( + (*ignore.parent.relative_to(repository).parts, ignore.name) + ) + for ignore in ignore_files + ) or any( + collides_with_candidates( + (*directory.relative_to(repository).parts, name) + ) + for directory in configured_excludes + for name in (".gitignore", ".ignore") + ) def install_ignore( directory: Path, @@ -404,20 +441,14 @@ def install_ignore( prepend: bool = False, ) -> None: relative = (*directory.relative_to(repository).parts, name) - - def collides_with(candidate: bytes) -> bool: - pairs = tuple( - zip(PurePosixPath(os.fsdecode(candidate)).parts, relative) - ) - return all( - actual.casefold() == synthetic.casefold() - for actual, synthetic in pairs - ) and any(actual != synthetic for actual, synthetic in pairs) - - collides = any(collides_with(candidate) for candidate in batch) - if collides: + if isolate_ignores: if directory != repository: - prefix = os.fsencode(directory.relative_to(repository).as_posix()) + prefix = os.fsencode( + "/".join( + re.escape(part) + for part in directory.relative_to(repository).parts + ) + ) rebased = [] for line in contents.splitlines(): if not line or line.startswith(b"#"): @@ -437,9 +468,12 @@ def collides_with(candidate: bytes) -> bool: + b"\n" ) contents = b"".join(rebased) - destination = temporary_root / f"ignore-{len(external_ignores)}" + position = len(external_ignores) + destination = temporary_root / f"ignore-{position}" destination.write_bytes(contents) - external_ignores.append(destination) + priority = -1 if prepend else IGNORE_FILE_NAMES.index(name) + depth = len(directory.relative_to(repository).parts) + external_ignores.append((priority, depth, position, destination)) return destination = probe.joinpath(*relative) @@ -491,7 +525,7 @@ def collides_with(candidate: bytes) -> bool: *command, *( argument - for ignore in external_ignores + for _, _, _, ignore in sorted(external_ignores) for argument in ("--ignore-file", str(ignore)) ), *(["--debug"] if directories_only else []), @@ -588,7 +622,7 @@ def owns_git_root(value: bytes, expected: Path) -> bool: worktree = ( run_git(["rev-parse", "--show-toplevel"]) - if (repository / ".git").exists() + if has_git_marker(repository) else None ) if worktree is not None and worktree.returncode: @@ -616,15 +650,7 @@ def owns_git_root(value: bytes, expected: Path) -> bool: if not valid_worktree: worktree = None - ripgrep_overrides = any( - PurePosixPath(os.fsdecode(row.removesuffix(b"\n"))).name in (".ignore", ".rgignore") - for row in rows - ) or any( - (ancestor / name).is_file() - for ancestor in ancestors - for name in (".ignore", ".rgignore") - ) - if selected.is_dir() and (worktree is None or ripgrep_overrides): + if selected.is_dir(): pending = [selected] inspected_directories: set[tuple[int, int]] = set() while pending: @@ -641,7 +667,7 @@ def owns_git_root(value: bytes, expected: Path) -> bool: ] valid_roots: set[tuple[int, int]] = set() for entry in children: - if (entry / ".git").exists(): + if has_git_marker(entry): candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) if candidate.returncode == 0: try: @@ -709,7 +735,7 @@ def visible_nested_root(root: Path) -> bool: } current = selected if selected.is_dir() else selected.parent while current != repository: - if (current / ".git").exists(): + if has_git_marker(current): nested_roots[directory_identity(current)] = current current = current.parent for index in range(len(listed)): @@ -717,7 +743,7 @@ def visible_nested_root(root: Path) -> bool: candidate = repository / os.fsdecode(relative) current = candidate if nonsymbolic_directory(candidate) else candidate.parent while current != repository: - if nonsymbolic_directory(current) and (current / ".git").exists(): + if nonsymbolic_directory(current) and has_git_marker(current): try: discovered = current.resolve(strict=True) discovered.relative_to(repository) @@ -792,7 +818,7 @@ def visible_nested_root(root: Path) -> bool: candidate = nested / os.fsdecode(relative) if not nonsymbolic_directory(candidate): continue - if not (candidate / ".git").exists(): + if not has_git_marker(candidate): continue try: discovered = candidate.resolve(strict=True) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index fb1fa092..095ee84e 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -415,11 +415,41 @@ describe("security scan file inventory", () => { ); }); + test("discovers checkout overrides that hide their own ignore files", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const container = join(checkout, "container"); + const nested = join(container, "mid", "nested"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "container/mid/\n"), + writeFile( + join(container, ".ignore"), + "*\n!mid/\n!mid/nested/\n!mid/nested/**\n", + ), + writeFile(join(nested, ".ignore"), "*\n"), + writeFile(join(nested, "tracked.ts"), "export {};\n"), + ]); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain( + "./container/mid/nested/tracked.ts", + ); + }); + test("keeps ignore scaffolding separate from case-distinct checkouts", async () => { if (Bun.which("rg") === null) return; const checkout = await repository(); - await writeFile(join(checkout, ".ignore"), ".IGNORE/private.ts\n"); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), ".IGNORE/tracked.ts\n"), + writeFile( + join(checkout, ".ignore"), + "!.IGNORE/tracked.ts\n.IGNORE/private.ts\n", + ), + ]); const nested = join(checkout, ".IGNORE"); try { await mkdir(nested); @@ -439,6 +469,33 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./.IGNORE/private.ts"); }); + test("preserves rgignore precedence for case-distinct checkout paths", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await Promise.all([ + writeFile(join(checkout, ".ignore"), "!.RGIGNORE/private.ts\n"), + writeFile(join(checkout, ".rgignore"), ".RGIGNORE/private.ts\n"), + ]); + const nested = join(checkout, ".RGIGNORE"); + try { + await mkdir(nested); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, "tracked.ts"), "visible\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + execFileSync("git", ["add", "tracked.ts", "private.ts"], { cwd: nested }); + + const rows = await inventory(checkout); + expect(rows).toContain("./.RGIGNORE/tracked.ts"); + expect(rows).not.toContain("./.RGIGNORE/private.ts"); + }); + test("keeps tracked files when an ignored snapshot checkout is explicitly selected", async () => { if (Bun.which("rg") === null) return; @@ -456,6 +513,23 @@ describe("security scan file inventory", () => { expect(await inventory(checkout, "ignored")).toEqual(["ignored/public.ts"]); }); + test("rejects symbolic Git metadata before reading another checkout", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const external = await repository(); + await symlink( + join(external, ".git"), + join(checkout, ".git"), + process.platform === "win32" ? "junction" : "dir", + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }); + test.skipIf(process.platform !== "win32")( "does not traverse external Windows directory junctions", async () => { From 548bd624683ffa518f429fcb28ec91172a2c4ef9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 02:29:44 -0700 Subject: [PATCH 038/106] fix(inventory): validate gitfiles and preserve native ignore semantics --- .../scripts/generate_in_scope_files.py | 42 ++++-- .../tests-ts/scan-inventory.test.ts | 137 +++++++++++++----- 2 files changed, 130 insertions(+), 49 deletions(-) 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 0fb5d278..0da31378 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import unicodedata from collections.abc import Iterator from pathlib import Path, PurePosixPath @@ -166,15 +167,35 @@ def nonsymbolic_directory(path: Path) -> bool: return stat.S_ISDIR(metadata.st_mode) and not symbolic_metadata(metadata) def has_git_marker(directory: Path) -> bool: + marker = directory / ".git" try: - metadata = (directory / ".git").stat(follow_symlinks=False) + metadata = marker.stat(follow_symlinks=False) except FileNotFoundError: return False except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if symbolic_metadata(metadata): raise InventoryError("symbolic Git metadata paths are not supported") - return stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode) + if not stat.S_ISREG(metadata.st_mode): + return stat.S_ISDIR(metadata.st_mode) + try: + contents = marker.read_bytes() + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if contents.startswith(b"gitdir: "): + gitdir = Path(os.fsdecode(contents.removeprefix(b"gitdir: ").rstrip(b"\r\n"))) + if not gitdir.is_absolute(): + gitdir = directory / gitdir + for current in reversed((gitdir, *gitdir.parents)): + try: + component = current.stat(follow_symlinks=False) + except FileNotFoundError: + break + except (OSError, ValueError) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if symbolic_metadata(component): + raise InventoryError("symbolic Git metadata paths are not supported") + return True discovered_roots: dict[tuple[int, int], Path] = {} metadata_aliases: set[tuple[str, ...]] = set() @@ -386,10 +407,14 @@ def visible_to_outer_ignores( return requested batches: list[tuple[dict[str, str], set[bytes]]] = [] + + def probe_name_key(value: str) -> str: + return unicodedata.normalize("NFC", value).casefold() + for relative in requested: parts = PurePosixPath(os.fsdecode(relative)).parts prefixes = { - "/".join(parts[: index + 1]).casefold(): "/".join(parts[: index + 1]) + probe_name_key("/".join(parts[: index + 1])): "/".join(parts[: index + 1]) for index in range(len(parts)) } for names, batch in batches: @@ -414,7 +439,7 @@ def collides_with_candidates(relative: tuple[str, ...]) -> bool: zip(PurePosixPath(os.fsdecode(candidate)).parts, relative) ) if all( - actual.casefold() == synthetic.casefold() + probe_name_key(actual) == probe_name_key(synthetic) for actual, synthetic in pairs ) and any(actual != synthetic for actual, synthetic in pairs): return True @@ -505,14 +530,7 @@ def install_ignore( contents += os.fsencode(f"!/{admitted}/\n") install_ignore(ignore.parent, ignore.name, contents) for directory, contents in configured_excludes.items(): - protects_exemption = preserve_gitignore_descendants and any( - directory.is_relative_to(owner) - and selected_root.is_relative_to(directory) - and directory != selected_root - for owner, selected_root in exempt_gitignores - ) - ignore_name = ".ignore" if protects_exemption else ".gitignore" - install_ignore(directory, ignore_name, contents, prepend=True) + install_ignore(directory, ".gitignore", contents, prepend=True) for relative in batch: destination = probe / os.fsdecode(relative) if directories_only: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 095ee84e..ac3b82d2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -37,6 +37,22 @@ async function repository(initializeGit = true): Promise { return checkout; } +function commit(checkout: string): void { + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track source", + ], + { cwd: checkout }, + ); +} + async function inventory(checkout: string, scope = "."): Promise { if (python === null) throw new Error("A Python interpreter is required."); const output = join(dirname(checkout), "inventory.txt"); @@ -221,19 +237,7 @@ describe("security scan file inventory", () => { writeFile(join(nested, "visible.ts"), "visible\n"), ]); execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); - execFileSync( - "git", - [ - "-c", - "user.name=Inventory Test", - "-c", - "user.email=inventory@example.test", - "commit", - "-qm", - "Track nested source", - ], - { cwd: nested }, - ); + commit(nested); execFileSync("git", ["add", "--force", "nested"], { cwd: checkout, stdio: "ignore", @@ -246,6 +250,17 @@ describe("security scan file inventory", () => { const scoped = await inventory(checkout, "nested"); expect(scoped).toContain("nested/visible.ts"); expect(scoped).not.toContain("nested/private.ts"); + + if (outerIgnore === ".git/info/exclude") { + await writeFile( + join(checkout, ".gitignore"), + "nested/\n!nested/private.ts\n", + ); + expect(await inventory(checkout)).toContain("./nested/private.ts"); + expect(await inventory(checkout, "nested")).toContain( + "nested/private.ts", + ); + } }, ); @@ -280,19 +295,7 @@ describe("security scan file inventory", () => { execFileSync("git", ["init", "-q"], { cwd: middle }); await writeFile(join(middle, "visible.ts"), "visible\n"); execFileSync("git", ["add", "visible.ts"], { cwd: middle }); - execFileSync( - "git", - [ - "-c", - "user.name=Inventory Test", - "-c", - "user.email=inventory@example.test", - "commit", - "-qm", - "Track intermediate source", - ], - { cwd: middle }, - ); + commit(middle); execFileSync("git", ["add", "middle"], { cwd: checkout, stdio: "ignore", @@ -496,6 +499,40 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./.RGIGNORE/private.ts"); }); + test("preserves canonically equivalent tracked directory spellings", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + const composed = join(nested, "caf\u00e9"); + const decomposed = join(nested, "cafe\u0301"); + await mkdir(composed, { recursive: true }); + try { + await mkdir(decomposed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "nested/private.ts\n"), + writeFile(join(nested, ".ignore"), "*\n"), + writeFile(join(composed, "first.ts"), "first\n"), + writeFile(join(decomposed, "second.ts"), "second\n"), + ]); + execFileSync( + "git", + ["add", "--force", "caf\u00e9/first.ts", "cafe\u0301/second.ts"], + { + cwd: nested, + }, + ); + + const rows = await inventory(checkout); + expect(rows).toContain("./nested/caf\u00e9/first.ts"); + expect(rows).toContain("./nested/cafe\u0301/second.ts"); + }); + test("keeps tracked files when an ignored snapshot checkout is explicitly selected", async () => { if (Bun.which("rg") === null) return; @@ -513,21 +550,47 @@ describe("security scan file inventory", () => { expect(await inventory(checkout, "ignored")).toEqual(["ignored/public.ts"]); }); - test("rejects symbolic Git metadata before reading another checkout", async () => { + test.each(["marker", "gitfile"])( + "rejects symbolic Git metadata through a %s", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const external = await repository(); + const metadata = + kind === "marker" + ? join(checkout, ".git") + : join(dirname(checkout), "linked-metadata"); + await symlink( + join(external, ".git"), + metadata, + process.platform === "win32" ? "junction" : "dir", + ); + if (kind === "gitfile") { + await writeFile(join(checkout, ".git"), `gitdir: ${metadata}\n`); + } + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + + test("inventories linked worktrees with regular Git metadata", async () => { if (Bun.which("rg") === null) return; - const checkout = await repository(false); - const external = await repository(); - await symlink( - join(external, ".git"), - join(checkout, ".git"), - process.platform === "win32" ? "junction" : "dir", - ); + const checkout = await repository(); await writeFile(join(checkout, "visible.ts"), "visible\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: checkout }); + commit(checkout); + const linked = join(dirname(checkout), "linked-worktree"); + execFileSync("git", ["worktree", "add", "--detach", linked, "HEAD"], { + cwd: checkout, + stdio: "ignore", + }); - await expect(inventory(checkout)).rejects.toThrow( - "symbolic Git metadata paths are not supported", - ); + expect(await inventory(linked)).toContain("./visible.ts"); }); test.skipIf(process.platform !== "win32")( From 219af56c3844d02281bdf24eefc2dfee9b8d2438 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 02:43:32 -0700 Subject: [PATCH 039/106] fix(inventory): sanitize Git discovery and preserve literal ignore rules --- .../scripts/generate_in_scope_files.py | 64 ++++++----- .../tests-ts/scan-inventory.test.ts | 105 ++++++++++++++---- 2 files changed, 119 insertions(+), 50 deletions(-) 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 0da31378..fc22659d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -117,6 +117,7 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: break current = current.parent ancestors.reverse() + deferred_ignore_checks: list[tuple[Path, os.stat_result]] = [] def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> None: for name in IGNORE_FILE_NAMES: @@ -127,30 +128,8 @@ def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> N if not symbolic_metadata(metadata) and stat.S_ISREG(metadata.st_mode): continue if allow_ignored and directory != repository: - ignored = subprocess.run( - [ - "git", - "-c", - "core.fsmonitor=false", - "-C", - str(repository), - "check-ignore", - "--quiet", - "--no-index", - "--", - directory.relative_to(repository).as_posix(), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - ripgrep_overrides = any( - (repository / parent / ignore).is_file() - for parent in directory.relative_to(repository).parents - for ignore in (".ignore", ".rgignore") - ) - if ignored.returncode == 0 and not ripgrep_overrides: - continue + deferred_ignore_checks.append((directory, metadata)) + continue if symbolic_metadata(metadata): raise InventoryError("symbolic ignore files are not supported") raise InventoryError("non-regular ignore files are not supported") @@ -399,7 +378,7 @@ def visible_to_outer_ignores( if exclude.is_file(): contents = exclude.read_bytes() if any( - line.strip() and not line.lstrip().startswith(b"#") + line.strip() and not line.startswith(b"#") for line in contents.splitlines() ): configured_excludes[directory] = contents @@ -668,6 +647,29 @@ def owns_git_root(value: bytes, expected: Path) -> bool: if not valid_worktree: worktree = None + for directory, metadata in deferred_ignore_checks: + if worktree is not None: + ignored = run_git( + [ + "check-ignore", + "--quiet", + "--no-index", + "--", + directory.relative_to(repository).as_posix(), + ], + literal=False, + ) + ripgrep_overrides = any( + (repository / parent / ignore).is_file() + for parent in directory.relative_to(repository).parents + for ignore in (".ignore", ".rgignore") + ) + if ignored.returncode == 0 and not ripgrep_overrides: + continue + if symbolic_metadata(metadata): + raise InventoryError("symbolic ignore files are not supported") + raise InventoryError("non-regular ignore files are not supported") + if selected.is_dir(): pending = [selected] inspected_directories: set[tuple[int, int]] = set() @@ -924,7 +926,7 @@ def visible_nested_root(root: Path) -> bool: or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} - directory_entries: dict[tuple[int, int], dict[str, list[Path]]] = {} + directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts ) @@ -977,7 +979,7 @@ def tracked_variants( if index < len(root_parts) or not case_insensitive_roots[root_identity]: if indexed != requested: return - elif os.fsdecode(indexed).casefold() != os.fsdecode(requested).casefold(): + elif indexed.lower() != requested.lower(): return def descend(parent: Path, index: int) -> list[Path]: @@ -986,19 +988,21 @@ def descend(parent: Path, index: int) -> list[Path]: except OSError: return [] if parent_identity not in directory_entries: - grouped: dict[str, list[Path]] = {} + grouped: dict[bytes, list[Path]] = {} try: with os.scandir(parent) as entries: for entry in entries: if not git_metadata_path(parent, entry.name): - grouped.setdefault(entry.name.casefold(), []).append( + grouped.setdefault(os.fsencode(entry.name).lower(), []).append( parent / entry.name ) except OSError: return [] directory_entries[parent_identity] = grouped component = components[index] - variants = directory_entries[parent_identity].get(component.casefold(), []) + variants = directory_entries[parent_identity].get( + os.fsencode(component).lower(), [] + ) exact = [candidate for candidate in variants if candidate.name == component] alternatives = [candidate for candidate in variants if candidate.name != component] groups = [exact] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ac3b82d2..da15bac9 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -53,7 +53,11 @@ function commit(checkout: string): void { ); } -async function inventory(checkout: string, scope = "."): Promise { +async function inventory( + checkout: string, + scope = ".", + env: NodeJS.ProcessEnv = process.env, +): Promise { if (python === null) throw new Error("A Python interpreter is required."); const output = join(dirname(checkout), "inventory.txt"); execFileSync( @@ -68,7 +72,7 @@ async function inventory(checkout: string, scope = "."): Promise { "--out", output, ], - { cwd: checkout, stdio: "pipe" }, + { cwd: checkout, env, stdio: "pipe" }, ); return (await readFile(output, "utf8")) .trimEnd() @@ -129,6 +133,35 @@ describe("security scan file inventory", () => { ]); }); + test.each([ + ["SS", "ss", true], + ["ss", "\u00df", false], + ])( + "matches indexed %s against replacement %s using Git case semantics", + async (indexed, replacement, expected) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await mkdir(join(checkout, indexed)); + await writeFile(join(checkout, indexed, "private.ts"), "tracked\n"); + execFileSync("git", ["add", `${indexed}/private.ts`], { cwd: checkout }); + execFileSync("git", ["config", "core.ignoreCase", "true"], { + cwd: checkout, + }); + await rm(join(checkout, indexed), { recursive: true }); + await mkdir(join(checkout, replacement)); + await writeFile( + join(checkout, replacement, "private.ts"), + "replacement\n", + ); + await writeFile(join(checkout, ".gitignore"), `${replacement}/\n`); + + expect( + (await inventory(checkout)).includes(`./${replacement}/private.ts`), + ).toBe(expected); + }, + ); + test("keeps an explicitly selected ignored file without widening its directory", async () => { if (Bun.which("rg") === null) return; @@ -264,26 +297,32 @@ describe("security scan file inventory", () => { }, ); - test("applies configured excludes from every enclosing Git checkout", async () => { - if (Bun.which("rg") === null) return; + test.each(["nested", " #nested"])( + "applies configured excludes from every enclosing checkout to %s", + async (directory) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const middle = join(checkout, "middle"); - const nested = join(middle, "nested"); - await mkdir(nested, { recursive: true }); - execFileSync("git", ["init", "-q"], { cwd: middle }); - execFileSync("git", ["init", "-q"], { cwd: nested }); - await Promise.all([ - writeFile(join(middle, ".git", "info", "exclude"), "nested/private.ts\n"), - writeFile(join(nested, "private.ts"), "private\n"), - writeFile(join(nested, "visible.ts"), "visible\n"), - ]); - execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); + const checkout = await repository(); + const middle = join(checkout, "middle"); + const nested = join(middle, directory); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: middle }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile( + join(middle, ".git", "info", "exclude"), + `${directory}/private.ts\n`, + ), + writeFile(join(nested, "private.ts"), "private\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + ]); + execFileSync("git", ["add", "private.ts", "visible.ts"], { cwd: nested }); - const rows = await inventory(checkout); - expect(rows).toContain("./middle/nested/visible.ts"); - expect(rows).not.toContain("./middle/nested/private.ts"); - }); + const rows = await inventory(checkout); + expect(rows).toContain(`./middle/${directory}/visible.ts`); + expect(rows).not.toContain(`./middle/${directory}/private.ts`); + }, + ); test("preserves intermediate ignores beneath an ancestor Git link", async () => { if (Bun.which("rg") === null) return; @@ -665,4 +704,30 @@ describe("security scan file inventory", () => { "symbolic ignore files are not supported", ); }); + + test.skipIf(process.platform === "win32")( + "rejects snapshot ignore links without discovering a parent checkout", + async () => { + if (Bun.which("rg") === null) return; + + const parent = await repository(); + const snapshot = join(parent, "snapshot"); + const visible = join(snapshot, "visible"); + const external = join(dirname(parent), "external.ignore"); + const trace = join(dirname(parent), "git-trace.log"); + await mkdir(visible, { recursive: true }); + await writeFile(external, "# ignore rules\n"); + await writeFile(join(visible, "source.ts"), "visible\n"); + await symlink(external, join(visible, ".ignore")); + + await expect( + inventory(snapshot, ".", { + ...process.env, + GIT_DIR: join(parent, ".git"), + GIT_TRACE: trace, + }), + ).rejects.toThrow("symbolic ignore files are not supported"); + await expect(readFile(trace, "utf8")).rejects.toThrow(); + }, + ); }); From 7a51e7027fdfe2102b4aca30ee873df6bd2fd55f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 02:51:46 -0700 Subject: [PATCH 040/106] fix(inventory): verify Unicode aliases by filesystem identity --- .../scripts/generate_in_scope_files.py | 38 +++++++++++++++---- .../tests-ts/scan-inventory.test.ts | 13 ++++++- 2 files changed, 41 insertions(+), 10 deletions(-) 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 fc22659d..c7926d18 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -927,6 +927,10 @@ def visible_nested_root(root: Path) -> bool: } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} + + def indexed_name_key(value: str) -> bytes: + return os.fsencode(unicodedata.normalize("NFC", value)).lower() + selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts ) @@ -979,7 +983,9 @@ def tracked_variants( if index < len(root_parts) or not case_insensitive_roots[root_identity]: if indexed != requested: return - elif indexed.lower() != requested.lower(): + elif indexed_name_key(os.fsdecode(indexed)) != indexed_name_key( + os.fsdecode(requested) + ): return def descend(parent: Path, index: int) -> list[Path]: @@ -993,7 +999,7 @@ def descend(parent: Path, index: int) -> list[Path]: with os.scandir(parent) as entries: for entry in entries: if not git_metadata_path(parent, entry.name): - grouped.setdefault(os.fsencode(entry.name).lower(), []).append( + grouped.setdefault(indexed_name_key(entry.name), []).append( parent / entry.name ) except OSError: @@ -1001,14 +1007,20 @@ def descend(parent: Path, index: int) -> list[Path]: directory_entries[parent_identity] = grouped component = components[index] variants = directory_entries[parent_identity].get( - os.fsencode(component).lower(), [] + indexed_name_key(component), [] ) exact = [candidate for candidate in variants if candidate.name == component] - alternatives = [candidate for candidate in variants if candidate.name != component] - groups = [exact] - if case_insensitive_roots[root_identity]: - groups.append(alternatives) - for group in groups: + alternatives = [ + candidate + for candidate in variants + if candidate.name != component + and ( + case_insensitive_roots[root_identity] + or unicodedata.normalize("NFC", candidate.name) + == unicodedata.normalize("NFC", component) + ) + ] + for group in (exact, alternatives): matches: list[Path] = [] for candidate in group: try: @@ -1017,6 +1029,16 @@ def descend(parent: Path, index: int) -> list[Path]: continue if symbolic_metadata(metadata): continue + if candidate.name != component: + try: + expected = (parent / component).stat(follow_symlinks=False) + except OSError: + continue + if symbolic_metadata(expected) or ( + metadata.st_dev, + metadata.st_ino, + ) != (expected.st_dev, expected.st_ino): + continue if index + 1 < len(components): if not stat.S_ISDIR(metadata.st_mode): continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index da15bac9..1a08679d 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -136,9 +136,10 @@ describe("security scan file inventory", () => { test.each([ ["SS", "ss", true], ["ss", "\u00df", false], + ["caf\u00e9", "cafe\u0301", true], ])( - "matches indexed %s against replacement %s using Git case semantics", - async (indexed, replacement, expected) => { + "matches indexed %s against replacement %s using filesystem identity", + async (indexed, replacement, allowAlias) => { if (Bun.which("rg") === null) return; const checkout = await repository(); @@ -155,6 +156,14 @@ describe("security scan file inventory", () => { "replacement\n", ); await writeFile(join(checkout, ".gitignore"), `${replacement}/\n`); + const expected = + allowAlias && + (await realpath(join(checkout, indexed, "private.ts")).then( + async (path) => + path === + (await realpath(join(checkout, replacement, "private.ts"))), + () => false, + )); expect( (await inventory(checkout)).includes(`./${replacement}/private.ts`), From aa4afc5f2edb32ac0aa40aa7e6c92a642cede2b8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:05:00 -0700 Subject: [PATCH 041/106] fix(inventory): validate Git inputs and scoped filesystem aliases --- .../scripts/generate_in_scope_files.py | 79 ++++++++++++++----- .../tests-ts/scan-inventory.test.ts | 33 ++++++++ 2 files changed, 93 insertions(+), 19 deletions(-) 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 c7926d18..f20315d8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -147,6 +147,18 @@ def nonsymbolic_directory(path: Path) -> bool: def has_git_marker(directory: Path) -> bool: marker = directory / ".git" + + def inspect_metadata(path: Path) -> os.stat_result | None: + try: + metadata = path.stat(follow_symlinks=False) + except FileNotFoundError: + return None + except (OSError, ValueError) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if symbolic_metadata(metadata): + raise InventoryError("symbolic Git metadata paths are not supported") + return metadata + try: metadata = marker.stat(follow_symlinks=False) except FileNotFoundError: @@ -155,25 +167,48 @@ def has_git_marker(directory: Path) -> bool: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if symbolic_metadata(metadata): raise InventoryError("symbolic Git metadata paths are not supported") - if not stat.S_ISREG(metadata.st_mode): - return stat.S_ISDIR(metadata.st_mode) - try: - contents = marker.read_bytes() - except OSError as error: - raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if contents.startswith(b"gitdir: "): + if stat.S_ISDIR(metadata.st_mode): + gitdir = marker + elif stat.S_ISREG(metadata.st_mode): + try: + contents = marker.read_bytes() + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if not contents.startswith(b"gitdir: "): + return True gitdir = Path(os.fsdecode(contents.removeprefix(b"gitdir: ").rstrip(b"\r\n"))) if not gitdir.is_absolute(): gitdir = directory / gitdir for current in reversed((gitdir, *gitdir.parents)): - try: - component = current.stat(follow_symlinks=False) - except FileNotFoundError: + if inspect_metadata(current) is None: + break + else: + return False + + roots = [gitdir] + common_marker = gitdir / "commondir" + common_metadata = inspect_metadata(common_marker) + if common_metadata is not None and stat.S_ISREG(common_metadata.st_mode): + try: + common = Path(os.fsdecode(common_marker.read_bytes().rstrip(b"\r\n"))) + except (OSError, ValueError) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if not common.is_absolute(): + common = gitdir / common + for current in reversed((common, *common.parents)): + if inspect_metadata(current) is None: break - except (OSError, ValueError) as error: - raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if symbolic_metadata(component): - raise InventoryError("symbolic Git metadata paths are not supported") + roots.append(common) + for root in roots: + for relative in ( + "HEAD", + "index", + "config", + "config.worktree", + "info", + "info/exclude", + ): + inspect_metadata(root / relative) return True discovered_roots: dict[tuple[int, int], Path] = {} @@ -980,11 +1015,17 @@ def tracked_variants( return for index, requested in enumerate(selected_parts): indexed = indexed_parts[index] - if index < len(root_parts) or not case_insensitive_roots[root_identity]: - if indexed != requested: - return - elif indexed_name_key(os.fsdecode(indexed)) != indexed_name_key( - os.fsdecode(requested) + if indexed == requested: + continue + if index < len(root_parts): + return + indexed_name = os.fsdecode(indexed) + requested_name = os.fsdecode(requested) + if unicodedata.normalize("NFC", indexed_name) != unicodedata.normalize( + "NFC", requested_name + ) and ( + not case_insensitive_roots[root_identity] + or indexed_name_key(indexed_name) != indexed_name_key(requested_name) ): return diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 1a08679d..69259999 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -168,6 +168,17 @@ describe("security scan file inventory", () => { expect( (await inventory(checkout)).includes(`./${replacement}/private.ts`), ).toBe(expected); + + if (indexed === "caf\u00e9") { + execFileSync("git", ["config", "core.ignoreCase", "false"], { + cwd: checkout, + }); + expect( + (await inventory(checkout, replacement)).includes( + `${replacement}/private.ts`, + ), + ).toBe(expected); + } }, ); @@ -625,6 +636,28 @@ describe("security scan file inventory", () => { }, ); + test + .skipIf(process.platform === "win32") + .each(["index", "config", "info/exclude"])( + "rejects a symbolic Git metadata %s", + async (relative) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + await writeFile(join(external, "source.ts"), "tracked\n"); + execFileSync("git", ["add", "source.ts"], { cwd: external }); + const metadata = join(checkout, ".git", relative); + await rm(metadata, { force: true }); + await symlink(join(external, ".git", relative), metadata); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test("inventories linked worktrees with regular Git metadata", async () => { if (Bun.which("rg") === null) return; From a848928ab7a021dc007331953c5094a0e0785b5c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:09:11 -0700 Subject: [PATCH 042/106] fix(inventory): keep slash-only ignore patterns inert --- .../scripts/generate_in_scope_files.py | 2 ++ .../tests-ts/scan-inventory.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) 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 f20315d8..7f9ef915 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -496,6 +496,8 @@ def install_ignore( pattern = line[1:] if negated else line if pattern.startswith(b"/"): pattern = pattern[1:] + if not pattern: + continue elif b"/" not in pattern.rstrip(b"/"): pattern = b"**/" + pattern rebased.append( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 69259999..d05a3b1f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -558,6 +558,30 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./.RGIGNORE/private.ts"); }); + test("keeps slash-only ignores inert when isolating nested checkout names", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const container = join(checkout, "container"); + const nested = join(container, ".IGNORE"); + await mkdir(container); + await writeFile(join(container, ".ignore"), "/\n"); + try { + await mkdir(nested); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, ".ignore"), "*\n"); + await writeFile(join(nested, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain( + "./container/.IGNORE/tracked.ts", + ); + }); + test("preserves canonically equivalent tracked directory spellings", async () => { if (Bun.which("rg") === null) return; From af7887ff798d0213aaf1791ea1383d385a343ef3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:17:49 -0700 Subject: [PATCH 043/106] fix(inventory): validate Git index metadata and exact scope roots --- .../scripts/generate_in_scope_files.py | 48 +++++++--- .../tests-ts/scan-inventory.test.ts | 90 +++++++++++++++++++ 2 files changed, 127 insertions(+), 11 deletions(-) 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 7f9ef915..310a87e7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -148,7 +148,9 @@ def nonsymbolic_directory(path: Path) -> bool: def has_git_marker(directory: Path) -> bool: marker = directory / ".git" - def inspect_metadata(path: Path) -> os.stat_result | None: + def inspect_metadata( + path: Path, *, directory: bool | None = None + ) -> os.stat_result | None: try: metadata = path.stat(follow_symlinks=False) except FileNotFoundError: @@ -157,6 +159,10 @@ def inspect_metadata(path: Path) -> os.stat_result | None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if symbolic_metadata(metadata): raise InventoryError("symbolic Git metadata paths are not supported") + if directory is True and not stat.S_ISDIR(metadata.st_mode): + raise InventoryError("non-directory Git metadata paths are not supported") + if directory is False and not stat.S_ISREG(metadata.st_mode): + raise InventoryError("non-regular Git metadata files are not supported") return metadata try: @@ -180,15 +186,15 @@ def inspect_metadata(path: Path) -> os.stat_result | None: if not gitdir.is_absolute(): gitdir = directory / gitdir for current in reversed((gitdir, *gitdir.parents)): - if inspect_metadata(current) is None: + if inspect_metadata(current, directory=True) is None: break else: return False roots = [gitdir] common_marker = gitdir / "commondir" - common_metadata = inspect_metadata(common_marker) - if common_metadata is not None and stat.S_ISREG(common_metadata.st_mode): + common_metadata = inspect_metadata(common_marker, directory=False) + if common_metadata is not None: try: common = Path(os.fsdecode(common_marker.read_bytes().rstrip(b"\r\n"))) except (OSError, ValueError) as error: @@ -196,7 +202,7 @@ def inspect_metadata(path: Path) -> os.stat_result | None: if not common.is_absolute(): common = gitdir / common for current in reversed((common, *common.parents)): - if inspect_metadata(current) is None: + if inspect_metadata(current, directory=True) is None: break roots.append(common) for root in roots: @@ -208,7 +214,17 @@ def inspect_metadata(path: Path) -> os.stat_result | None: "info", "info/exclude", ): - inspect_metadata(root / relative) + inspect_metadata(root / relative, directory=relative == "info") + try: + shared_indexes = ( + entry for entry in root.iterdir() if entry.name.startswith("sharedindex.") + ) + for shared_index in shared_indexes: + inspect_metadata(shared_index, directory=False) + except FileNotFoundError: + continue + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error return True discovered_roots: dict[tuple[int, int], Path] = {} @@ -778,12 +794,22 @@ def listed_paths(index: int) -> Iterator[bytes]: ) def visible_nested_root(root: Path) -> bool: - return ( - selected == repository - or selected == root - or selected.is_relative_to(root) - or root.is_relative_to(selected) + selected_parts = selected.relative_to(repository).parts + root_parts = root.relative_to(repository).parts + shared_depth = min(len(selected_parts), len(root_parts)) + if selected_parts[:shared_depth] != root_parts[:shared_depth]: + return False + selected_ancestor = ( + selected + if len(selected_parts) == shared_depth + else selected.parents[len(selected_parts) - shared_depth - 1] + ) + root_ancestor = ( + root + if len(root_parts) == shared_depth + else root.parents[len(root_parts) - shared_depth - 1] ) + return directory_identity(selected_ancestor) == directory_identity(root_ancestor) nested_roots = { identity: root diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d05a3b1f..93ba4875 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, + readdir, readFile, realpath, rm, @@ -660,6 +661,65 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32")( + "rejects split-index backing files that leave the checkout", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await writeFile(join(checkout, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + execFileSync("git", ["update-index", "--split-index"], { + cwd: checkout, + }); + const gitdir = join(checkout, ".git"); + const shared = (await readdir(gitdir)).find((name) => + name.startsWith("sharedindex."), + ); + if (shared === undefined) throw new Error("Expected a split Git index."); + + expect(await inventory(checkout)).toContain("./tracked.ts"); + const original = join(gitdir, shared); + const external = join(dirname(checkout), shared); + await writeFile(external, await readFile(original)); + await rm(original); + await symlink(external, original); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + + test.skipIf(process.platform === "win32")( + "rejects non-regular Git metadata before invoking Git", + async () => { + if (Bun.which("rg") === null || Bun.which("mkfifo") === null) return; + + const checkout = await repository(); + const config = join(checkout, ".git", "config"); + await rm(config); + execFileSync("mkfifo", [config]); + + await expect(inventory(checkout)).rejects.toThrow( + "non-regular Git metadata files are not supported", + ); + }, + ); + + test("requires the Git info metadata path to be a directory", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const info = join(checkout, ".git", "info"); + await rm(info, { recursive: true }); + await writeFile(info, "not a directory\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "non-directory Git metadata paths are not supported", + ); + }); + test .skipIf(process.platform === "win32") .each(["index", "config", "info/exclude"])( @@ -698,6 +758,36 @@ describe("security scan file inventory", () => { expect(await inventory(linked)).toContain("./visible.ts"); }); + test("does not inspect a differently cased checkout outside an explicit scope", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const selected = join(checkout, "NESTED"); + const unselected = join(checkout, "nested"); + await mkdir(selected); + try { + await mkdir(unselected); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + for (const nested of [selected, unselected]) { + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + } + const trace = join(dirname(checkout), "git-trace.log"); + + expect( + await inventory(checkout, "NESTED", { + ...process.env, + GIT_TRACE: trace, + GIT_TRACE_SETUP: "1", + }), + ).toEqual(["NESTED/tracked.ts"]); + expect(await readFile(trace, "utf8")).not.toContain(unselected); + }); + test.skipIf(process.platform !== "win32")( "does not traverse external Windows directory junctions", async () => { From e90c4fd6c6edc346b38ff574b01301f5daa200b8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:20:58 -0700 Subject: [PATCH 044/106] fix(inventory): reject config includes and preserve visible overrides --- .../scripts/generate_in_scope_files.py | 16 ++++++--- .../tests-ts/scan-inventory.test.ts | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) 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 310a87e7..0138b298 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -16,6 +16,7 @@ from pathlib import Path, PurePosixPath IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") +GIT_CONFIG_INCLUDE = re.compile(rb"(?im)^[ \t]*\[[ \t]*include(?:if)?(?=[ \t\]])") class InventoryError(ValueError): @@ -214,7 +215,15 @@ def inspect_metadata( "info", "info/exclude", ): - inspect_metadata(root / relative, directory=relative == "info") + path = root / relative + metadata = inspect_metadata(path, directory=relative == "info") + if metadata is not None and relative in ("config", "config.worktree"): + try: + contents = path.read_bytes() + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if GIT_CONFIG_INCLUDE.search(contents): + raise InventoryError("Git config includes are not supported") try: shared_indexes = ( entry for entry in root.iterdir() if entry.name.startswith("sharedindex.") @@ -982,10 +991,7 @@ def visible_nested_root(root: Path) -> bool: and path.removeprefix(b"./") == os.fsencode(selected.relative_to(repository).as_posix()) ) - or ( - worktree is None - and not any(path.startswith(root) for root in inspected_prefixes) - ) + or not any(path.startswith(root) for root in inspected_prefixes) or any(path.startswith(worktree) for worktree in nested_worktrees) } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 93ba4875..2909e6a7 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -111,6 +111,22 @@ describe("security scan file inventory", () => { ]); }); + test.each([".ignore", ".rgignore"])( + "keeps ordinary files re-included by higher-precedence %s rules", + async (override) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await Promise.all([ + writeFile(join(checkout, ".gitignore"), "source.ts\n"), + writeFile(join(checkout, override), "!source.ts\n"), + writeFile(join(checkout, "source.ts"), "visible\n"), + ]); + + expect(await inventory(checkout)).toContain("./source.ts"); + }, + ); + test("applies snapshot ignores without inheriting unrelated parent rules", async () => { if (Bun.which("rg") === null) return; @@ -691,6 +707,26 @@ describe("security scan file inventory", () => { }, ); + test.each(["include", 'includeIf "gitdir:**"'])( + "rejects repository-directed %s config before invoking Git", + async (section) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = join(dirname(checkout), "external.config"); + await writeFile(external, "[core]\n\tignoreCase = true\n"); + const config = join(checkout, ".git", "config"); + await writeFile( + config, + `${await readFile(config, "utf8")}[${section}]\n\tpath = ${external}\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "Git config includes are not supported", + ); + }, + ); + test.skipIf(process.platform === "win32")( "rejects non-regular Git metadata before invoking Git", async () => { From 9fae5cf66f477f4374016f737a94a3b77d88ac4a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:27:06 -0700 Subject: [PATCH 045/106] fix(inventory): reject BOM-prefixed Git config includes --- .../scripts/generate_in_scope_files.py | 2 +- sdk/typescript/tests-ts/scan-inventory.test.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) 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 0138b298..2469041d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -222,7 +222,7 @@ def inspect_metadata( contents = path.read_bytes() except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if GIT_CONFIG_INCLUDE.search(contents): + if GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): raise InventoryError("Git config includes are not supported") try: shared_indexes = ( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 2909e6a7..5d1e51cc 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -707,9 +707,13 @@ describe("security scan file inventory", () => { }, ); - test.each(["include", 'includeIf "gitdir:**"'])( - "rejects repository-directed %s config before invoking Git", - async (section) => { + test.each([ + ["include", "without BOM"], + ['includeIf "gitdir:**"', "without BOM"], + ["include", "with BOM"], + ])( + "rejects repository-directed %s config %s before invoking Git", + async (section, bom) => { if (Bun.which("rg") === null) return; const checkout = await repository(); @@ -718,7 +722,7 @@ describe("security scan file inventory", () => { const config = join(checkout, ".git", "config"); await writeFile( config, - `${await readFile(config, "utf8")}[${section}]\n\tpath = ${external}\n`, + `${bom === "with BOM" ? "\ufeff" : ""}[${section}]\n\tpath = ${external}\n${await readFile(config, "utf8")}`, ); await expect(inventory(checkout)).rejects.toThrow( From ec70253c46b0ab123b5bd387e9e51b90887000aa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:36:57 -0700 Subject: [PATCH 046/106] fix(inventory): preserve native nested ignore semantics --- .../scripts/generate_in_scope_files.py | 59 +---------------- .../tests-ts/scan-inventory.test.ts | 65 +++++++++++++++++-- 2 files changed, 62 insertions(+), 62 deletions(-) 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 2469041d..a77a1fd6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -514,15 +514,15 @@ def install_ignore( ) ) rebased = [] - for line in contents.splitlines(): + for line in contents.removeprefix(b"\xef\xbb\xbf").splitlines(): if not line or line.startswith(b"#"): continue negated = line.startswith(b"!") pattern = line[1:] if negated else line + if not pattern.rstrip(b" ").strip(b"/"): + continue if pattern.startswith(b"/"): pattern = pattern[1:] - if not pattern: - continue elif b"/" not in pattern.rstrip(b"/"): pattern = b"**/" + pattern rebased.append( @@ -920,11 +920,6 @@ def visible_nested_root(root: Path) -> bool: if directory_identity(discovered) not in inspected_roots: pending_roots.append(discovered) - allowed = { - normalized(prefix + relative) - for index in range(len(listed)) - for relative in listed_paths(index) - } if scope not in (".", "./"): for identity, (root, _) in list(cached_by_root.items()): tracked = run_git(["ls-files", "--cached", "-z"], directory=root) @@ -946,54 +941,6 @@ def visible_nested_root(root: Path) -> bool: f"git config exited with status {setting.returncode}: {detail}" ) case_insensitive_roots[identity] = setting.stdout.strip().lower() == b"true" - inspected_prefixes = tuple( - normalized(prefix + os.fsencode(root.relative_to(repository).as_posix()) + b"/") - for root in inspected_roots.values() - ) - nested_worktrees = tuple( - path - for path in allowed - if path.endswith(b"/") and path not in inspected_prefixes - ) - explicitly_ignored = False - enclosing_roots = list(inspected_roots.values()) - if worktree is not None: - enclosing_roots.append(repository) - if scope not in (".", "./") and any( - selected.is_relative_to(root) for root in enclosing_roots - ): - enclosing = max( - (root for root in enclosing_roots if selected.is_relative_to(root)), - key=lambda root: len(root.parts), - ) - explicit_relative = selected.relative_to(enclosing).as_posix() - explicit_path = f"./{explicit_relative}" - ignored = run_git( - ["check-ignore", "--quiet", "--no-index", "--", explicit_path], - directory=enclosing, - literal=False, - ) - if ignored.returncode not in (0, 1): - detail = ignored.stderr.decode("utf-8", errors="replace").strip() - message = f"git check-ignore exited with status {ignored.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) - explicitly_ignored = ignored.returncode == 0 and selected.is_file() - - if not explicitly_ignored: - rows = { - row - for row in rows - if (path := normalized(row.removesuffix(b"\n"))) in allowed - or ( - selected.is_file() - and path.removeprefix(b"./") - == os.fsencode(selected.relative_to(repository).as_posix()) - ) - or not any(path.startswith(root) for root in inspected_prefixes) - or any(path.startswith(worktree) for worktree in nested_worktrees) - } recorded = {normalized(row.removesuffix(b"\n")) for row in rows} directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 5d1e51cc..8e8cb465 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -127,6 +127,26 @@ describe("security scan file inventory", () => { }, ); + test.each([".ignore", ".rgignore"])( + "keeps nested checkout files re-included by higher-precedence %s rules", + async (override) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, ".gitignore"), "source.ts\n"), + writeFile(join(nested, override), "!source.ts\n"), + writeFile(join(nested, "source.ts"), "visible\n"), + ]); + + expect(await inventory(checkout)).toContain("./nested/source.ts"); + expect(await inventory(checkout, "nested")).toContain("nested/source.ts"); + }, + ); + test("applies snapshot ignores without inheriting unrelated parent rules", async () => { if (Bun.which("rg") === null) return; @@ -575,14 +595,44 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./.RGIGNORE/private.ts"); }); - test("keeps slash-only ignores inert when isolating nested checkout names", async () => { + test.each([ + ["slash-only", "/\n"], + ["whitespace-only", " \n"], + ])( + "keeps %s ignores inert when isolating nested checkout names", + async (_description, contents) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const container = join(checkout, "container"); + const nested = join(container, ".IGNORE"); + await mkdir(container); + await writeFile(join(container, ".ignore"), contents); + try { + await mkdir(nested); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, ".ignore"), "*\n"); + await writeFile(join(nested, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + + expect(await inventory(checkout)).toContain( + "./container/.IGNORE/tracked.ts", + ); + }, + ); + + test("preserves BOM-prefixed ignores when isolating nested checkout names", async () => { if (Bun.which("rg") === null) return; const checkout = await repository(); const container = join(checkout, "container"); const nested = join(container, ".IGNORE"); await mkdir(container); - await writeFile(join(container, ".ignore"), "/\n"); + await writeFile(join(container, ".ignore"), "\ufeff.IGNORE/private.ts\n"); try { await mkdir(nested); } catch (error) { @@ -592,11 +642,14 @@ describe("security scan file inventory", () => { execFileSync("git", ["init", "-q"], { cwd: nested }); await writeFile(join(nested, ".ignore"), "*\n"); await writeFile(join(nested, "tracked.ts"), "tracked\n"); - execFileSync("git", ["add", "tracked.ts"], { cwd: nested }); + await writeFile(join(nested, "private.ts"), "private\n"); + execFileSync("git", ["add", "tracked.ts", "private.ts"], { + cwd: nested, + }); - expect(await inventory(checkout)).toContain( - "./container/.IGNORE/tracked.ts", - ); + const rows = await inventory(checkout); + expect(rows).toContain("./container/.IGNORE/tracked.ts"); + expect(rows).not.toContain("./container/.IGNORE/private.ts"); }); test("preserves canonically equivalent tracked directory spellings", async () => { From 30e7aefe22eecc96b52373c8bb0093fa4e403def Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:45:51 -0700 Subject: [PATCH 047/106] fix(inventory): reject ambiguous newline-bearing directory names --- .../scripts/generate_in_scope_files.py | 2 ++ .../tests-ts/scan-inventory.test.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+) 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 a77a1fd6..ed27e52c 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -404,6 +404,8 @@ def visible_to_outer_ignores( normalized(os.fsencode(candidate.relative_to(repository).as_posix())) for candidate in candidates } + if directories_only and any(b"\n" in path or b"\r" in path for path in requested): + raise InventoryError("line separators are not supported in inventory paths") directories: list[Path] = [] current = root.parent while True: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 8e8cb465..d46e6ea3 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -494,6 +494,27 @@ describe("security scan file inventory", () => { ); }); + test.skipIf(process.platform === "win32")( + "rejects line-separated snapshot directory names before parsing ignore diagnostics", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const nested = join(checkout, "victim", "nested"); + await mkdir(nested, { recursive: true }); + await mkdir(join(checkout, "evil\nrg: DEBUG|x: ignoring victim")); + await writeFile(join(checkout, ".ignore"), "evil*\n"); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, ".ignore"), "*\n"); + await writeFile(join(nested, "private.ts"), "tracked\n"); + execFileSync("git", ["add", "private.ts"], { cwd: nested }); + + await expect(inventory(checkout)).rejects.toThrow( + "line separators are not supported in inventory paths", + ); + }, + ); + test("discovers Git-hidden checkouts reopened by ripgrep ignore rules", async () => { if (Bun.which("rg") === null) return; From 5e9914fa66e2eb434114e9a00392165eef9d25be Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:49:09 -0700 Subject: [PATCH 048/106] fix(inventory): bind Git worktrees and admit tracked Gitlinks --- .../scripts/generate_in_scope_files.py | 40 ++++++++------- .../tests-ts/scan-inventory.test.ts | 51 +++++++++++++++++++ 2 files changed, 73 insertions(+), 18 deletions(-) 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 ed27e52c..f47b005c 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -552,27 +552,31 @@ def install_ignore( contents += separator + existing destination.write_bytes(contents) + def admit_gitlink_directories(directory: Path, contents: bytes) -> bytes: + if not preserve_gitignore_descendants: + return contents + for owner, selected_root in exempt_gitignores: + if not ( + directory.is_relative_to(owner) + and selected_root.is_relative_to(directory) + and directory != selected_root + ): + continue + parts = selected_root.relative_to(directory).parts + for index in range(len(parts)): + if contents and not contents.endswith(b"\n"): + contents += b"\n" + admitted = "/".join(re.escape(part) for part in parts[: index + 1]) + contents += os.fsencode(f"!/{admitted}/\n") + return contents + for ignore in ignore_files: contents = ignore.read_bytes() - if preserve_gitignore_descendants and ignore.name == ".gitignore": - for owner, selected_root in exempt_gitignores: - directory = ignore.parent - if not ( - directory.is_relative_to(owner) - and selected_root.is_relative_to(directory) - and directory != selected_root - ): - continue - parts = selected_root.relative_to(directory).parts - for index in range(len(parts)): - if contents and not contents.endswith(b"\n"): - contents += b"\n" - admitted = "/".join( - re.escape(part) for part in parts[: index + 1] - ) - contents += os.fsencode(f"!/{admitted}/\n") + if ignore.name == ".gitignore": + contents = admit_gitlink_directories(ignore.parent, contents) install_ignore(ignore.parent, ignore.name, contents) for directory, contents in configured_excludes.items(): + contents = admit_gitlink_directories(directory, contents) install_ignore(directory, ".gitignore", contents, prepend=True) for relative in batch: destination = probe / os.fsdecode(relative) @@ -659,7 +663,7 @@ def run_git( git_environment.pop("GIT_LITERAL_PATHSPECS", None) try: return subprocess.run( - [*command, *arguments], + [*command, f"--work-tree={directory}", *arguments], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d46e6ea3..6f4dff94 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -409,6 +409,35 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); + test("admits tracked Gitlinks through configured directory excludes", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, "visible.ts"), "visible\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + execFileSync("git", ["add", "visible.ts", "private.ts"], { + cwd: nested, + }); + commit(nested); + execFileSync("git", ["add", "nested"], { + cwd: checkout, + stdio: "ignore", + }); + await writeFile( + join(checkout, ".git", "info", "exclude"), + "nested/\nnested/private.ts\n", + ); + + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); + }); + test.each([ ["visible", "nested/private.ts\n"], ["ignored", "nested/\nnested/private.ts\n"], @@ -751,6 +780,28 @@ describe("security scan file inventory", () => { }, ); + test("binds Git discovery to the selected checkout despite external core.worktree", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = join(dirname(checkout), "external-worktree"); + const trace = join(dirname(checkout), "git-trace.log"); + await mkdir(external); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + execFileSync("git", ["config", "--local", "core.worktree", external], { + cwd: checkout, + }); + + expect( + await inventory(checkout, ".", { + ...process.env, + GIT_TRACE: trace, + GIT_TRACE_SETUP: "1", + }), + ).toEqual(["./visible.ts"]); + expect(await readFile(trace, "utf8")).not.toContain(external); + }); + test.skipIf(process.platform === "win32")( "rejects split-index backing files that leave the checkout", async () => { From 77d4ad088c220898e3e44e675a113a9fd96c1bd7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 03:58:44 -0700 Subject: [PATCH 049/106] fix(inventory): verify linked worktree gitdir ownership --- .../scripts/generate_in_scope_files.py | 15 ++++++++++ .../tests-ts/scan-inventory.test.ts | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+) 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 f47b005c..f0a56c24 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -186,9 +186,24 @@ def inspect_metadata( gitdir = Path(os.fsdecode(contents.removeprefix(b"gitdir: ").rstrip(b"\r\n"))) if not gitdir.is_absolute(): gitdir = directory / gitdir + gitdir = Path(os.path.abspath(gitdir)) for current in reversed((gitdir, *gitdir.parents)): if inspect_metadata(current, directory=True) is None: break + try: + gitdir.relative_to(repository) + except ValueError: + backpointer = gitdir / "gitdir" + if inspect_metadata(backpointer, directory=False) is None: + raise InventoryError("Git metadata directory does not own selected worktree") + try: + target = Path(os.fsdecode(backpointer.read_bytes().rstrip(b"\r\n"))) + except (OSError, ValueError) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if not target.is_absolute(): + target = gitdir / target + if Path(os.path.abspath(target)).parts != marker.parts: + raise InventoryError("Git metadata directory does not own selected worktree") else: return False diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6f4dff94..5891251c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -780,6 +780,34 @@ describe("security scan file inventory", () => { }, ); + test.each(["missing", "mismatched"])( + "rejects an external gitdir with a %s worktree backpointer", + async (ownership) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(false); + const external = await repository(); + await writeFile(join(external, "secret.ts"), "tracked\n"); + execFileSync("git", ["add", "secret.ts"], { cwd: external }); + await writeFile(join(checkout, ".gitignore"), "secret.ts\n"); + await writeFile(join(checkout, "secret.ts"), "private\n"); + await writeFile( + join(checkout, ".git"), + `gitdir: ${join(external, ".git")}\n`, + ); + if (ownership === "mismatched") { + await writeFile( + join(external, ".git", "gitdir"), + `${join(external, ".git")}\n`, + ); + } + + await expect(inventory(checkout)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + }, + ); + test("binds Git discovery to the selected checkout despite external core.worktree", async () => { if (Bun.which("rg") === null) return; From 33d1f3613909f7b18b72b8ab831269978898f71a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:07:30 -0700 Subject: [PATCH 050/106] fix(inventory): validate linked-worktree common metadata --- .../scripts/generate_in_scope_files.py | 3 +++ sdk/typescript/tests-ts/scan-inventory.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) 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 f0a56c24..e6ceed00 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -217,6 +217,9 @@ def inspect_metadata( raise InventoryError(f"could not inspect Git metadata: {directory}") from error if not common.is_absolute(): common = gitdir / common + common = Path(os.path.abspath(common)) + if (common / "worktrees" / gitdir.name).parts != gitdir.parts: + raise InventoryError("Git common directory does not own selected worktree") for current in reversed((common, *common.parents)): if inspect_metadata(current, directory=True) is None: break diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 5891251c..520d544f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -808,6 +808,22 @@ describe("security scan file inventory", () => { }, ); + test("rejects unrelated external Git common directories", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + await writeFile( + join(checkout, ".git", "commondir"), + `${join(external, ".git")}\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "Git common directory does not own selected worktree", + ); + }); + test("binds Git discovery to the selected checkout despite external core.worktree", async () => { if (Bun.which("rg") === null) return; From 180ad0893a5291356126e161adb6f6d6ae4e39f9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:15:58 -0700 Subject: [PATCH 051/106] fix(inventory): validate ignore sources before scoped discovery --- .../scripts/generate_in_scope_files.py | 169 ++++++++---------- .../tests-ts/scan-inventory.test.ts | 40 +++++ 2 files changed, 114 insertions(+), 95 deletions(-) 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 e6ceed00..e6b868a9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -118,9 +118,7 @@ def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: break current = current.parent ancestors.reverse() - deferred_ignore_checks: list[tuple[Path, os.stat_result]] = [] - - def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> None: + def reject_symbolic_ignore(directory: Path) -> None: for name in IGNORE_FILE_NAMES: try: metadata = (directory / name).stat(follow_symlinks=False) @@ -128,9 +126,6 @@ def reject_symbolic_ignore(directory: Path, *, allow_ignored: bool = False) -> N continue if not symbolic_metadata(metadata) and stat.S_ISREG(metadata.st_mode): continue - if allow_ignored and directory != repository: - deferred_ignore_checks.append((directory, metadata)) - continue if symbolic_metadata(metadata): raise InventoryError("symbolic ignore files are not supported") raise InventoryError("non-regular ignore files are not supported") @@ -190,9 +185,14 @@ def inspect_metadata( for current in reversed((gitdir, *gitdir.parents)): if inspect_metadata(current, directory=True) is None: break - try: - gitdir.relative_to(repository) - except ValueError: + repository_parts = repository.parts + internally_owned = gitdir.parts[: len(repository_parts)] == repository_parts + if internally_owned: + ancestor = gitdir + for _ in range(len(gitdir.parts) - len(repository_parts)): + ancestor = ancestor.parent + internally_owned = directory_identity(ancestor) == directory_identity(repository) + if not internally_owned: backpointer = gitdir / "gitdir" if inspect_metadata(backpointer, directory=False) is None: raise InventoryError("Git metadata directory does not own selected worktree") @@ -287,10 +287,6 @@ def ripgrep_inventory( relative_alias = "/".join(re.escape(part) for part in alias[len(directory_parts) :]) if relative_alias and "\n" not in relative_alias and "\r" not in relative_alias: ignored_aliases.append(f"/{relative_alias}\n") - for name in IGNORE_FILE_NAMES: - ignore = directory / name - if ignore.is_file() and not ignore.is_symlink(): - arguments.extend(["--ignore-file", str(ignore)]) with tempfile.TemporaryDirectory() as temporary_directory, tempfile.TemporaryFile( mode="w+b" ) as inventory: @@ -358,58 +354,6 @@ def inventory_paths() -> Iterator[bytes]: def normalized(path: bytes) -> bytes: return path.replace(b"\\", b"/") if os.name == "nt" else path - rows = ripgrep_inventory(repository, scope) - visible_directories = set(ancestors) - for row in rows: - current = (repository / os.fsdecode(row.removesuffix(b"\n"))).parent - while current != repository: - visible_directories.add(current) - current = current.parent - for directory in sorted(visible_directories): - reject_symbolic_ignore(directory, allow_ignored=True) - if directory != repository and has_git_marker(directory): - discovered_roots[directory_identity(directory)] = directory - if not selected.is_dir(): - continue - for entry in directory.iterdir(): - if entry.name != ".git" and git_metadata_path(directory, entry.name): - metadata_aliases.add(entry.relative_to(repository).parts) - elif ( - entry.name != ".git" - and nonsymbolic_directory(entry) - and has_git_marker(entry) - ): - discovered_roots[directory_identity(entry)] = entry - if metadata_aliases: - rows = { - row - for row in rows - if not any( - Path(os.fsdecode(row.removesuffix(b"\n"))).parts[: len(alias)] == alias - for alias in metadata_aliases - ) - } - if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( - repository, scope, directory_guard=True - ): - rows.clear() - for ancestor in ancestors[1:]: - if not any((ancestor / name).is_file() for name in IGNORE_FILE_NAMES): - continue - ancestor_scope = selected.relative_to(ancestor).as_posix() or "." - ancestor_prefix = os.fsencode(ancestor.relative_to(repository).as_posix()) + b"/" - visible = { - normalized( - ancestor_prefix + normalized(row.removesuffix(b"\n")).removeprefix(b"./") - ) - for row in ripgrep_inventory(ancestor, ancestor_scope) - } - rows = { - row - for row in rows - if normalized(row.removesuffix(b"\n")).removeprefix(b"./") in visible - } - def visible_to_outer_ignores( root: Path, candidates: list[Path], @@ -733,29 +677,7 @@ def owns_git_root(value: bytes, expected: Path) -> bool: if not valid_worktree: worktree = None - for directory, metadata in deferred_ignore_checks: - if worktree is not None: - ignored = run_git( - [ - "check-ignore", - "--quiet", - "--no-index", - "--", - directory.relative_to(repository).as_posix(), - ], - literal=False, - ) - ripgrep_overrides = any( - (repository / parent / ignore).is_file() - for parent in directory.relative_to(repository).parents - for ignore in (".ignore", ".rgignore") - ) - if ignored.returncode == 0 and not ripgrep_overrides: - continue - if symbolic_metadata(metadata): - raise InventoryError("symbolic ignore files are not supported") - raise InventoryError("non-regular ignore files are not supported") - + scoped_files: dict[Path, list[Path]] = {} if selected.is_dir(): pending = [selected] inspected_directories: set[tuple[int, int]] = set() @@ -766,12 +688,20 @@ def owns_git_root(value: bytes, expected: Path) -> bool: continue inspected_directories.add(identity) reject_symbolic_ignore(directory) + entries = list(directory.iterdir()) children = [ entry - for entry in directory.iterdir() + for entry in entries if nonsymbolic_directory(entry) and not git_metadata_path(directory, entry.name) ] - valid_roots: set[tuple[int, int]] = set() + if scope not in (".", "./"): + scoped_files[directory] = [ + entry + for entry in entries + if not git_metadata_path(directory, entry.name) + and not entry.is_symlink() + and entry.is_file() + ] for entry in children: if has_git_marker(entry): candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) @@ -780,20 +710,69 @@ def owns_git_root(value: bytes, expected: Path) -> bool: if owns_git_root(candidate.stdout, entry): identity = directory_identity(entry) discovered_roots[identity] = entry - valid_roots.add(identity) except (OSError, ValueError): pass - ordinary = [entry for entry in children if directory_identity(entry) not in valid_roots] - if ordinary: + if children: visible = visible_to_outer_ignores( - ordinary[0], ordinary, directories_only=True + children[0], children, directories_only=True ) pending.extend( entry - for entry in ordinary + for entry in children if normalized(os.fsencode(entry.relative_to(repository).as_posix())) in visible ) + rows = ripgrep_inventory(repository, scope) + visible_directories = set(ancestors) + for row in rows: + current = (repository / os.fsdecode(row.removesuffix(b"\n"))).parent + while current != repository: + visible_directories.add(current) + current = current.parent + for directory in sorted(visible_directories): + reject_symbolic_ignore(directory) + if directory != repository and has_git_marker(directory): + discovered_roots[directory_identity(directory)] = directory + if not selected.is_dir(): + continue + for entry in directory.iterdir(): + if entry.name != ".git" and git_metadata_path(directory, entry.name): + metadata_aliases.add(entry.relative_to(repository).parts) + elif ( + entry.name != ".git" + and nonsymbolic_directory(entry) + and has_git_marker(entry) + ): + discovered_roots[directory_identity(entry)] = entry + if metadata_aliases: + rows = { + row + for row in rows + if not any( + Path(os.fsdecode(row.removesuffix(b"\n"))).parts[: len(alias)] == alias + for alias in metadata_aliases + ) + } + if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( + repository, scope, directory_guard=True + ): + rows.clear() + elif scope not in (".", "./"): + prefix = b"./" if scope.startswith("./") else b"" + for candidates in scoped_files.values(): + if not candidates: + continue + visible = visible_to_outer_ignores(candidates[0], candidates) + for candidate in candidates: + relative = normalized(os.fsencode(candidate.relative_to(repository).as_posix())) + if b"\n" in relative or b"\r" in relative: + raise InventoryError("line separators are not supported in inventory paths") + row = prefix + relative + b"\n" + if relative in visible: + rows.add(row) + else: + rows.discard(row) + if worktree is not None or discovered_roots: prefix = b"./" if scope == "." or scope.startswith("./") else b"" listed: list[list[bytes]] = [[], []] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 520d544f..700c7734 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -127,6 +127,27 @@ describe("security scan file inventory", () => { }, ); + test.each([".ignore", ".rgignore"])( + "preserves ancestor %s precedence for explicit directory scopes", + async (override) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + await Promise.all([ + writeFile(join(checkout, override), "!nested/visible.ts\n"), + writeFile(join(nested, ".gitignore"), "*.ts\n"), + writeFile(join(nested, "visible.ts"), "visible\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + + const rows = await inventory(checkout, "nested"); + expect(rows).toContain("nested/visible.ts"); + expect(rows).not.toContain("nested/private.ts"); + }, + ); + test.each([".ignore", ".rgignore"])( "keeps nested checkout files re-included by higher-precedence %s rules", async (override) => { @@ -1070,6 +1091,25 @@ describe("security scan file inventory", () => { ); }); + test.skipIf(process.platform === "win32")( + "rejects descendant ignore links before invoking repository-wide ripgrep", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const visible = join(checkout, "visible"); + const external = join(dirname(checkout), "external.ignore"); + await mkdir(visible); + await writeFile(join(visible, "source.ts"), "visible\n"); + await writeFile(external, "source.ts\n"); + await symlink(external, join(visible, ".ignore")); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic ignore files are not supported", + ); + }, + ); + test.skipIf(process.platform === "win32")( "rejects snapshot ignore links without discovering a parent checkout", async () => { From 928d4b0465b54a200c4ed8ab7d375c38f42e949b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:21:49 -0700 Subject: [PATCH 052/106] fix(inventory): batch ignore probes and contain Git paths --- .../scripts/generate_in_scope_files.py | 96 ++++++++++++------- .../tests-ts/scan-inventory.test.ts | 63 ++++++++++++ 2 files changed, 126 insertions(+), 33 deletions(-) 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 e6b868a9..a2260ebe 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -218,7 +218,11 @@ def inspect_metadata( if not common.is_absolute(): common = gitdir / common common = Path(os.path.abspath(common)) - if (common / "worktrees" / gitdir.name).parts != gitdir.parts: + owner = common / "worktrees" / gitdir.name + equivalent = tuple( + unicodedata.normalize("NFC", part).casefold() for part in owner.parts + ) == tuple(unicodedata.normalize("NFC", part).casefold() for part in gitdir.parts) + if not equivalent or directory_identity(owner) != directory_identity(gitdir): raise InventoryError("Git common directory does not own selected worktree") for current in reversed((common, *common.parents)): if inspect_metadata(current, directory=True) is None: @@ -682,40 +686,56 @@ def owns_git_root(value: bytes, expected: Path) -> bool: pending = [selected] inspected_directories: set[tuple[int, int]] = set() while pending: - directory = pending.pop() - identity = directory_identity(directory) - if identity in inspected_directories: - continue - inspected_directories.add(identity) - reject_symbolic_ignore(directory) - entries = list(directory.iterdir()) - children = [ - entry - for entry in entries - if nonsymbolic_directory(entry) and not git_metadata_path(directory, entry.name) - ] - if scope not in (".", "./"): - scoped_files[directory] = [ + visibility_groups: dict[tuple[tuple[str, ...], ...], list[Path]] = {} + for directory in pending: + identity = directory_identity(directory) + if identity in inspected_directories: + continue + inspected_directories.add(identity) + reject_symbolic_ignore(directory) + entries = list(directory.iterdir()) + children = [ entry for entry in entries - if not git_metadata_path(directory, entry.name) - and not entry.is_symlink() - and entry.is_file() + if nonsymbolic_directory(entry) + and not git_metadata_path(directory, entry.name) ] - for entry in children: - if has_git_marker(entry): - candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) - if candidate.returncode == 0: - try: - if owns_git_root(candidate.stdout, entry): - identity = directory_identity(entry) - discovered_roots[identity] = entry - except (OSError, ValueError): - pass - if children: - visible = visible_to_outer_ignores( - children[0], children, directories_only=True - ) + if scope not in (".", "./"): + scoped_files[directory] = [ + entry + for entry in entries + if not git_metadata_path(directory, entry.name) + and not entry.is_symlink() + and entry.is_file() + ] + for entry in children: + if has_git_marker(entry): + candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) + if candidate.returncode == 0: + try: + if owns_git_root(candidate.stdout, entry): + discovered_roots[directory_identity(entry)] = entry + except (OSError, ValueError): + pass + if children: + context: list[tuple[str, ...]] = [] + current = directory + while True: + relative = current.relative_to(repository).parts + context.extend( + (*relative, name) + for name in IGNORE_FILE_NAMES + if (current / name).is_file() + ) + if has_git_marker(current): + context.append((*relative, ".git")) + if current == repository: + break + current = current.parent + visibility_groups.setdefault(tuple(context), []).extend(children) + pending = [] + for children in visibility_groups.values(): + visible = visible_to_outer_ignores(children[0], children, directories_only=True) pending.extend( entry for entry in children @@ -778,6 +798,15 @@ def owns_git_root(value: bytes, expected: Path) -> bool: listed: list[list[bytes]] = [[], []] cached_by_root: dict[tuple[int, int], tuple[Path, list[bytes]]] = {} + def validated_git_path(relative: bytes) -> bytes: + portable = normalized(relative) + components = portable.removesuffix(b"/").split(b"/") + if Path(os.fsdecode(portable)).is_absolute() or any( + component in (b"", b".", b"..") for component in components + ): + raise InventoryError("out-of-scope Git inventory paths are not supported") + return relative + def listed_paths(index: int) -> Iterator[bytes]: for chunk in listed[index]: for relative in chunk.split(b"\0"): @@ -785,7 +814,7 @@ def listed_paths(index: int) -> Iterator[bytes]: continue if b"\n" in relative or b"\r" in relative: raise InventoryError("line separators are not supported in inventory paths") - yield relative + yield validated_git_path(relative) if worktree is not None: for index, arguments in enumerate( @@ -910,6 +939,7 @@ def visible_nested_root(root: Path) -> bool: for relative in result.stdout.split(b"\0"): if not relative: continue + validated_git_path(relative) candidate = nested / os.fsdecode(relative) if not nonsymbolic_directory(candidate): continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 700c7734..597db22b 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -127,6 +127,27 @@ describe("security scan file inventory", () => { }, ); + test("shares ignore probes across directories with the same rules", async () => { + if (Bun.which("rg") === null) return; + + async function countLookups(branches: number): Promise { + const checkout = await repository(); + const trace = join(dirname(checkout), "git-trace.log"); + await writeFile(join(checkout, ".ignore"), "ignored/\n"); + for (let index = 0; index < branches; index++) { + const nested = join(checkout, `branch-${index}`, "nested"); + await mkdir(nested, { recursive: true }); + await writeFile(join(nested, "source.ts"), "visible\n"); + } + await inventory(checkout, ".", { ...process.env, GIT_TRACE: trace }); + return ( + (await readFile(trace, "utf8")).match(/--git-path info\/exclude/g) ?? [] + ).length; + } + + expect(await countLookups(18)).toBeLessThanOrEqual(await countLookups(2)); + }); + test.each([".ignore", ".rgignore"])( "preserves ancestor %s precedence for explicit directory scopes", async (override) => { @@ -867,6 +888,35 @@ describe("security scan file inventory", () => { expect(await readFile(trace, "utf8")).not.toContain(external); }); + test.skipIf(process.platform === "win32")( + "rejects escaped Git index entries before probing sibling metadata", + async () => { + const git = Bun.which("git"); + if (Bun.which("rg") === null || git === null) return; + + const checkout = await repository(); + const outside = join(dirname(checkout), "outside"); + const wrappers = join(dirname(checkout), "bin"); + await mkdir(outside); + await mkdir(wrappers); + execFileSync("git", ["init", "-q"], { cwd: outside }); + await writeFile(join(checkout, "source.ts"), "visible\n"); + const wrapper = join(wrappers, "git"); + await writeFile( + wrapper, + `#!/bin/sh\ncase " $* " in\n *" ls-files --cached "*) printf '../outside\\000' ;;\n *) exec ${JSON.stringify(git)} "$@" ;;\nesac\n`, + ); + await chmod(wrapper, 0o755); + + await expect( + inventory(checkout, ".", { + ...process.env, + PATH: `${wrappers}:${process.env["PATH"] ?? ""}`, + }), + ).rejects.toThrow("out-of-scope Git inventory paths are not supported"); + }, + ); + test.skipIf(process.platform === "win32")( "rejects split-index backing files that leave the checkout", async () => { @@ -986,6 +1036,19 @@ describe("security scan file inventory", () => { }); expect(await inventory(linked)).toContain("./visible.ts"); + + const aliased = join(dirname(checkout), "REPOSITORY", ".git"); + const equivalent = await realpath(aliased).then( + async (resolved) => resolved === (await realpath(join(checkout, ".git"))), + () => false, + ); + if (!equivalent) return; + const gitdir = (await readFile(join(linked, ".git"), "utf8")) + .replace(/^gitdir: /, "") + .trim(); + await writeFile(join(gitdir, "commondir"), `${aliased}\n`); + + expect(await inventory(linked)).toContain("./visible.ts"); }); test("does not inspect a differently cased checkout outside an explicit scope", async () => { From 0c336ae1d31cb6b219e2a8b3e15cd8f2595a2ef5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:25:28 -0700 Subject: [PATCH 053/106] fix(inventory): contain object metadata and skip ignored checkouts --- .../scripts/generate_in_scope_files.py | 52 +++++++++---------- .../tests-ts/scan-inventory.test.ts | 47 ++++++++++++++++- 2 files changed, 72 insertions(+), 27 deletions(-) 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 a2260ebe..000afe4f 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -236,9 +236,14 @@ def inspect_metadata( "config.worktree", "info", "info/exclude", + "objects", + "objects/info", + "objects/info/alternates", ): path = root / relative - metadata = inspect_metadata(path, directory=relative == "info") + metadata = inspect_metadata( + path, directory=relative in ("info", "objects", "objects/info") + ) if metadata is not None and relative in ("config", "config.worktree"): try: contents = path.read_bytes() @@ -682,9 +687,9 @@ def owns_git_root(value: bytes, expected: Path) -> bool: worktree = None scoped_files: dict[Path, list[Path]] = {} + inspected_directories: set[tuple[int, int]] = set() if selected.is_dir(): pending = [selected] - inspected_directories: set[tuple[int, int]] = set() while pending: visibility_groups: dict[tuple[tuple[str, ...], ...], list[Path]] = {} for directory in pending: @@ -708,15 +713,6 @@ def owns_git_root(value: bytes, expected: Path) -> bool: and not entry.is_symlink() and entry.is_file() ] - for entry in children: - if has_git_marker(entry): - candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) - if candidate.returncode == 0: - try: - if owns_git_root(candidate.stdout, entry): - discovered_roots[directory_identity(entry)] = entry - except (OSError, ValueError): - pass if children: context: list[tuple[str, ...]] = [] current = directory @@ -736,11 +732,18 @@ def owns_git_root(value: bytes, expected: Path) -> bool: pending = [] for children in visibility_groups.values(): visible = visible_to_outer_ignores(children[0], children, directories_only=True) - pending.extend( - entry - for entry in children - if normalized(os.fsencode(entry.relative_to(repository).as_posix())) in visible - ) + for entry in children: + if normalized(os.fsencode(entry.relative_to(repository).as_posix())) not in visible: + continue + if has_git_marker(entry): + candidate = run_git(["rev-parse", "--show-toplevel"], directory=entry) + if candidate.returncode == 0: + try: + if owns_git_root(candidate.stdout, entry): + discovered_roots[directory_identity(entry)] = entry + except (OSError, ValueError): + pass + pending.append(entry) rows = ripgrep_inventory(repository, scope) visible_directories = set(ancestors) @@ -758,12 +761,6 @@ def owns_git_root(value: bytes, expected: Path) -> bool: for entry in directory.iterdir(): if entry.name != ".git" and git_metadata_path(directory, entry.name): metadata_aliases.add(entry.relative_to(repository).parts) - elif ( - entry.name != ".git" - and nonsymbolic_directory(entry) - and has_git_marker(entry) - ): - discovered_roots[directory_identity(entry)] = entry if metadata_aliases: rows = { row @@ -820,7 +817,7 @@ def listed_paths(index: int) -> Iterator[bytes]: for index, arguments in enumerate( (["--cached"], ["--others", "--exclude-standard"]) ): - result = run_git(["ls-files", *arguments, "-z", "--", scope]) + result = run_git(["ls-files", "--sparse", *arguments, "-z", "--", scope]) if result.returncode: detail = result.stderr.decode("utf-8", errors="replace").strip() message = f"git ls-files exited with status {result.returncode}" @@ -867,6 +864,9 @@ def visible_nested_root(root: Path) -> bool: candidate = repository / os.fsdecode(relative) current = candidate if nonsymbolic_directory(candidate) else candidate.parent while current != repository: + if index != 0 and directory_identity(current) not in inspected_directories: + current = current.parent + continue if nonsymbolic_directory(current) and has_git_marker(current): try: discovered = current.resolve(strict=True) @@ -916,7 +916,7 @@ def visible_nested_root(root: Path) -> bool: (["--cached"], ["--others", "--exclude-standard"]) ): result = run_git( - ["ls-files", *arguments, "-z", "--", nested_scope], + ["ls-files", "--sparse", *arguments, "-z", "--", nested_scope], directory=nested, ) if result.returncode: @@ -955,7 +955,7 @@ def visible_nested_root(root: Path) -> bool: if scope not in (".", "./"): for identity, (root, _) in list(cached_by_root.items()): - tracked = run_git(["ls-files", "--cached", "-z"], directory=root) + tracked = run_git(["ls-files", "--sparse", "--cached", "-z"], directory=root) if tracked.returncode: detail = tracked.stderr.decode("utf-8", errors="replace").strip() raise InventoryError( @@ -992,7 +992,7 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: tracked_gitlinks = [] for owner, _tracked_paths in cached_by_root.values(): - staged = run_git(["ls-files", "--stage", "-z"], directory=owner) + staged = run_git(["ls-files", "--sparse", "--stage", "-z"], directory=owner) if staged.returncode: detail = staged.stderr.decode("utf-8", errors="replace").strip() raise InventoryError(f"git ls-files --stage exited with status {staged.returncode}: {detail}") diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 597db22b..0ef3f576 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -530,6 +530,27 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./nested/private.ts"); }); + test.each([".ignore", ".rgignore", ".gitignore"])( + "does not inspect checkout metadata excluded by outer %s rules", + async (ignore) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + const external = join(dirname(checkout), "external.config"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(external, "[core]\n\tignoreCase = true\n"); + execFileSync("git", ["config", "--local", "include.path", external], { + cwd: nested, + }); + await writeFile(join(checkout, ignore), "nested/\n"); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); + test("discovers self-hidden checkouts through visible snapshot directories", async () => { if (Bun.which("rg") === null) return; @@ -888,6 +909,30 @@ describe("security scan file inventory", () => { expect(await readFile(trace, "utf8")).not.toContain(external); }); + test + .skipIf(process.platform === "win32") + .each(["objects", "objects/info/alternates"])( + "rejects symbolic Git object metadata at %s", + async (relative) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const metadata = join(checkout, ".git", relative); + const target = + relative === "objects" + ? join(external, ".git", "objects") + : join(dirname(checkout), "external-alternates"); + if (relative !== "objects") await writeFile(target, "external\n"); + await rm(metadata, { recursive: relative === "objects", force: true }); + await symlink(target, metadata); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test.skipIf(process.platform === "win32")( "rejects escaped Git index entries before probing sibling metadata", async () => { @@ -904,7 +949,7 @@ describe("security scan file inventory", () => { const wrapper = join(wrappers, "git"); await writeFile( wrapper, - `#!/bin/sh\ncase " $* " in\n *" ls-files --cached "*) printf '../outside\\000' ;;\n *) exec ${JSON.stringify(git)} "$@" ;;\nesac\n`, + `#!/bin/sh\ncase " $* " in\n *" ls-files --sparse --cached "*) printf '../outside\\000' ;;\n *) exec ${JSON.stringify(git)} "$@" ;;\nesac\n`, ); await chmod(wrapper, 0o755); From 1d9d9f50d35c735729a4fd4d21b08725d40d3737 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:36:13 -0700 Subject: [PATCH 054/106] fix(inventory): validate refs and preserve ignore line bytes --- .../scripts/generate_in_scope_files.py | 18 +++++- .../tests-ts/scan-inventory.test.ts | 59 +++++++++++-------- 2 files changed, 52 insertions(+), 25 deletions(-) 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 000afe4f..da1bbfb7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -232,17 +232,30 @@ def inspect_metadata( for relative in ( "HEAD", "index", + "packed-refs", + "refs", + "refs/heads", + "refs/tags", "config", "config.worktree", "info", "info/exclude", + "info/sparse-checkout", "objects", "objects/info", "objects/info/alternates", ): path = root / relative metadata = inspect_metadata( - path, directory=relative in ("info", "objects", "objects/info") + path, + directory=relative in ( + "refs", + "refs/heads", + "refs/tags", + "info", + "objects", + "objects/info", + ), ) if metadata is not None and relative in ("config", "config.worktree"): try: @@ -487,7 +500,8 @@ def install_ignore( ) ) rebased = [] - for line in contents.removeprefix(b"\xef\xbb\xbf").splitlines(): + for line in contents.removeprefix(b"\xef\xbb\xbf").split(b"\n"): + line = line.removesuffix(b"\r") if not line or line.startswith(b"#"): continue negated = line.startswith(b"!") diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 0ef3f576..2225ab2c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -711,6 +711,7 @@ describe("security scan file inventory", () => { test.each([ ["slash-only", "/\n"], ["whitespace-only", " \n"], + ["embedded-carriage-return", ".IGNORE/tracked.ts\rignored\n"], ])( "keeps %s ignores inert when isolating nested checkout names", async (_description, contents) => { @@ -1016,15 +1017,17 @@ describe("security scan file inventory", () => { }, ); - test.skipIf(process.platform === "win32")( - "rejects non-regular Git metadata before invoking Git", - async () => { + test + .skipIf(process.platform === "win32") + .each(["config", "info/sparse-checkout"])( + "rejects non-regular Git %s metadata before invoking Git", + async (relative) => { if (Bun.which("rg") === null || Bun.which("mkfifo") === null) return; const checkout = await repository(); - const config = join(checkout, ".git", "config"); - await rm(config); - execFileSync("mkfifo", [config]); + const metadata = join(checkout, ".git", relative); + await rm(metadata, { force: true }); + execFileSync("mkfifo", [metadata]); await expect(inventory(checkout)).rejects.toThrow( "non-regular Git metadata files are not supported", @@ -1047,25 +1050,35 @@ describe("security scan file inventory", () => { test .skipIf(process.platform === "win32") - .each(["index", "config", "info/exclude"])( - "rejects a symbolic Git metadata %s", - async (relative) => { - if (Bun.which("rg") === null) return; + .each([ + "index", + "config", + "info/exclude", + "info/sparse-checkout", + "packed-refs", + "refs", + "refs/heads", + ])("rejects a symbolic Git metadata %s", async (relative) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const external = await repository(); - await writeFile(join(external, "source.ts"), "tracked\n"); - execFileSync("git", ["add", "source.ts"], { cwd: external }); - const metadata = join(checkout, ".git", relative); - await rm(metadata, { force: true }); - await symlink(join(external, ".git", relative), metadata); - await writeFile(join(checkout, "visible.ts"), "visible\n"); + const checkout = await repository(); + const external = await repository(); + await writeFile(join(external, "source.ts"), "tracked\n"); + execFileSync("git", ["add", "source.ts"], { cwd: external }); + const metadata = join(checkout, ".git", relative); + const directory = relative === "refs" || relative === "refs/heads"; + const target = join(external, ".git", relative); + if (relative === "info/sparse-checkout" || relative === "packed-refs") { + await writeFile(target, "external\n"); + } + await rm(metadata, { recursive: directory, force: true }); + await symlink(target, metadata); + await writeFile(join(checkout, "visible.ts"), "visible\n"); - await expect(inventory(checkout)).rejects.toThrow( - "symbolic Git metadata paths are not supported", - ); - }, - ); + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }); test("inventories linked worktrees with regular Git metadata", async () => { if (Bun.which("rg") === null) return; From 579c56f75b06c3ea30b1ef248ba6903bae338f04 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:42:03 -0700 Subject: [PATCH 055/106] fix(inventory): contain alternates and recovered Git paths --- .../scripts/generate_in_scope_files.py | 46 +++++++++- .../tests-ts/scan-inventory.test.ts | 89 +++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) 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 da1bbfb7..e003fead 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -257,12 +257,44 @@ def inspect_metadata( "objects/info", ), ) - if metadata is not None and relative in ("config", "config.worktree"): + if metadata is not None and relative in ( + "config", + "config.worktree", + "objects/info/alternates", + ): try: contents = path.read_bytes() except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): + if relative == "objects/info/alternates": + for line in contents.split(b"\n"): + line = line.removesuffix(b"\r") + if not line: + continue + alternate = Path(os.fsdecode(line)) + if not alternate.is_absolute(): + alternate = root / "objects" / alternate + alternate = Path(os.path.abspath(alternate)) + owner = next( + ( + candidate + for candidate in (repository, *roots) + if alternate.is_relative_to(candidate) + ), + None, + ) + if owner is None: + raise InventoryError( + "external Git object alternates are not supported" + ) + current = owner + for component in alternate.relative_to(owner).parts: + current /= component + if inspect_metadata(current, directory=True) is None: + raise InventoryError( + "missing Git object alternates are not supported" + ) + elif GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): raise InventoryError("Git config includes are not supported") try: shared_indexes = ( @@ -397,6 +429,8 @@ def visible_to_outer_ignores( if current == repository: break current = current.parent + for directory in directories: + reject_symbolic_ignore(directory) ignore_files = [ directory / name for directory in directories @@ -812,8 +846,12 @@ def owns_git_root(value: bytes, expected: Path) -> bool: def validated_git_path(relative: bytes) -> bytes: portable = normalized(relative) components = portable.removesuffix(b"/").split(b"/") - if Path(os.fsdecode(portable)).is_absolute() or any( - component in (b"", b".", b"..") for component in components + path = Path(os.fsdecode(portable)) + if ( + path.is_absolute() + or path.drive + or not (repository / path).is_relative_to(repository) + or any(component in (b"", b".", b"..") for component in components) ): raise InventoryError("out-of-scope Git inventory paths are not supported") return relative diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 2225ab2c..c0aad64a 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, mkdir, @@ -480,6 +481,34 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./nested/private.ts"); }); + test.skipIf(process.platform === "win32").each([".ignore", ".rgignore"])( + "rejects a hidden Gitlink ancestor's symbolic %s", + async (name) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const hidden = join(checkout, "hidden"); + const nested = join(hidden, "nested"); + const external = join(dirname(checkout), "external.ignore"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile(join(nested, "private.ts"), "tracked\n"); + execFileSync("git", ["add", "private.ts"], { cwd: nested }); + commit(nested); + await writeFile(join(checkout, ".gitignore"), "hidden/\n"); + execFileSync("git", ["add", "--force", "hidden/nested"], { + cwd: checkout, + stdio: "ignore", + }); + await writeFile(external, "nested/private.ts\n"); + await symlink(external, join(hidden, name)); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic ignore files are not supported", + ); + }, + ); + test.each([ ["visible", "nested/private.ts\n"], ["ignored", "nested/\nnested/private.ts\n"], @@ -934,6 +963,39 @@ describe("security scan file inventory", () => { }, ); + test("rejects external Git object alternates before invoking Git", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const trace = join(dirname(checkout), "git-trace.log"); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${join(external, ".git", "objects")}\n`, + ); + + await expect( + inventory(checkout, ".", { ...process.env, GIT_TRACE: trace }), + ).rejects.toThrow("external Git object alternates are not supported"); + await expect(readFile(trace, "utf8")).rejects.toThrow(); + }); + + test("allows repository-owned Git object alternates", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${objects}\n`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }); + test.skipIf(process.platform === "win32")( "rejects escaped Git index entries before probing sibling metadata", async () => { @@ -963,6 +1025,33 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform !== "win32")( + "rejects drive-relative Git index entries before probing another drive", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const original = "D-checkout"; + await writeFile(join(checkout, original), "tracked\n"); + execFileSync("git", ["add", original], { cwd: checkout }); + const indexPath = join(checkout, ".git", "index"); + const index = await readFile(indexPath); + const offset = index.indexOf(Buffer.from(`${original}\0`)); + if (offset === -1) + throw new Error("Expected the staged Git index entry."); + index.write("D:checkout", offset, "utf8"); + createHash("sha1") + .update(index.subarray(0, index.length - 20)) + .digest() + .copy(index, index.length - 20); + await writeFile(indexPath, index); + + await expect(inventory(checkout)).rejects.toThrow( + "out-of-scope Git inventory paths are not supported", + ); + }, + ); + test.skipIf(process.platform === "win32")( "rejects split-index backing files that leave the checkout", async () => { From 2220d4238ae6b93f2a537fd1df1a2a9e35ad0130 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:44:50 -0700 Subject: [PATCH 056/106] fix(inventory): reject symbolic Git index ancestors --- .../scripts/generate_in_scope_files.py | 11 +++++++ .../tests-ts/scan-inventory.test.ts | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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 e003fead..c5dabfb7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -854,6 +854,17 @@ def validated_git_path(relative: bytes) -> bytes: or any(component in (b"", b".", b"..") for component in components) ): raise InventoryError("out-of-scope Git inventory paths are not supported") + current = repository + for component in path.parts[:-1]: + current /= component + try: + metadata = current.stat(follow_symlinks=False) + except OSError: + break + if symbolic_metadata(metadata): + raise InventoryError("symbolic Git inventory paths are not supported") + if not stat.S_ISDIR(metadata.st_mode): + break return relative def listed_paths(index: int) -> Iterator[bytes]: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index c0aad64a..a0827b4d 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1025,6 +1025,35 @@ describe("security scan file inventory", () => { }, ); + test("rejects symbolic ancestors in Git-listed paths before reading external metadata", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const link = join(checkout, "link"); + await mkdir(link); + await writeFile(join(link, "nested"), "tracked\n"); + execFileSync("git", ["add", "link/nested"], { cwd: checkout }); + + const external = await repository(); + const nested = join(external, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile( + join(nested, ".git", "config"), + "[include]\n\tpath = outside\n", + ); + await rm(link, { recursive: true }); + await symlink( + external, + link, + process.platform === "win32" ? "junction" : "dir", + ); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git inventory paths are not supported", + ); + }); + test.skipIf(process.platform !== "win32")( "rejects drive-relative Git index entries before probing another drive", async () => { From 5d6dff20eee262ea321129a36f97ddca4e5d189f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:55:49 -0700 Subject: [PATCH 057/106] fix(inventory): preserve exact checkout ownership and aliases --- .../scripts/generate_in_scope_files.py | 37 +++--- .../tests-ts/scan-inventory.test.ts | 121 ++++++++++++++---- 2 files changed, 112 insertions(+), 46 deletions(-) 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 c5dabfb7..8071a4bf 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -279,11 +279,15 @@ def inspect_metadata( ( candidate for candidate in (repository, *roots) - if alternate.is_relative_to(candidate) + if alternate.parts[: len(candidate.parts)] == candidate.parts ), None, ) - if owner is None: + anchor = alternate + if owner is not None: + for _ in range(len(alternate.parts) - len(owner.parts)): + anchor = anchor.parent + if owner is None or directory_identity(anchor) != directory_identity(owner): raise InventoryError( "external Git object alternates are not supported" ) @@ -747,6 +751,11 @@ def owns_git_root(value: bytes, expected: Path) -> bool: inspected_directories.add(identity) reject_symbolic_ignore(directory) entries = list(directory.iterdir()) + metadata_aliases.update( + entry.relative_to(repository).parts + for entry in entries + if entry.name != ".git" and git_metadata_path(directory, entry.name) + ) children = [ entry for entry in entries @@ -804,20 +813,6 @@ def owns_git_root(value: bytes, expected: Path) -> bool: reject_symbolic_ignore(directory) if directory != repository and has_git_marker(directory): discovered_roots[directory_identity(directory)] = directory - if not selected.is_dir(): - continue - for entry in directory.iterdir(): - if entry.name != ".git" and git_metadata_path(directory, entry.name): - metadata_aliases.add(entry.relative_to(repository).parts) - if metadata_aliases: - rows = { - row - for row in rows - if not any( - Path(os.fsdecode(row.removesuffix(b"\n"))).parts[: len(alias)] == alias - for alias in metadata_aliases - ) - } if selected.is_dir() and scope not in (".", "./") and not ripgrep_inventory( repository, scope, directory_guard=True ): @@ -843,18 +838,18 @@ def owns_git_root(value: bytes, expected: Path) -> bool: listed: list[list[bytes]] = [[], []] cached_by_root: dict[tuple[int, int], tuple[Path, list[bytes]]] = {} - def validated_git_path(relative: bytes) -> bytes: + def validated_git_path(relative: bytes, root: Path = repository) -> bytes: portable = normalized(relative) components = portable.removesuffix(b"/").split(b"/") path = Path(os.fsdecode(portable)) if ( path.is_absolute() or path.drive - or not (repository / path).is_relative_to(repository) + or not (root / path).is_relative_to(root) or any(component in (b"", b".", b"..") for component in components) ): raise InventoryError("out-of-scope Git inventory paths are not supported") - current = repository + current = root for component in path.parts[:-1]: current /= component try: @@ -1002,7 +997,7 @@ def visible_nested_root(root: Path) -> bool: for relative in result.stdout.split(b"\0"): if not relative: continue - validated_git_path(relative) + validated_git_path(relative, nested) candidate = nested / os.fsdecode(relative) if not nonsymbolic_directory(candidate): continue @@ -1041,7 +1036,7 @@ def visible_nested_root(root: Path) -> bool: directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} def indexed_name_key(value: str) -> bytes: - return os.fsencode(unicodedata.normalize("NFC", value)).lower() + return os.fsencode(unicodedata.normalize("NFC", value).lower()) selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index a0827b4d..d0b84e8a 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -7,6 +7,7 @@ import { readdir, readFile, realpath, + rename, rm, symlink, writeFile, @@ -215,6 +216,7 @@ describe("security scan file inventory", () => { test.each([ ["SS", "ss", true], + ["Ä", "ä", true], ["ss", "\u00df", false], ["caf\u00e9", "cafe\u0301", true], ])( @@ -980,6 +982,28 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test("rejects differently cased sibling Git object alternates", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const sibling = join(dirname(checkout), "REPOSITORY"); + try { + await mkdir(sibling); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + execFileSync("git", ["init", "-q"], { cwd: sibling }); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${join(sibling, ".git", "objects")}\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "external Git object alternates are not supported", + ); + }); + test("allows repository-owned Git object alternates", async () => { if (Bun.which("rg") === null) return; @@ -1025,34 +1049,42 @@ describe("security scan file inventory", () => { }, ); - test("rejects symbolic ancestors in Git-listed paths before reading external metadata", async () => { - if (Bun.which("rg") === null) return; + test.each(["selected", "nested"])( + "rejects symbolic ancestors in %s Git-listed paths before reading external metadata", + async (kind) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const link = join(checkout, "link"); - await mkdir(link); - await writeFile(join(link, "nested"), "tracked\n"); - execFileSync("git", ["add", "link/nested"], { cwd: checkout }); + const checkout = await repository(); + const owner = kind === "selected" ? checkout : join(checkout, "nested"); + if (kind === "nested") { + await mkdir(owner); + execFileSync("git", ["init", "-q"], { cwd: owner }); + } + const link = join(owner, "link"); + await mkdir(link); + await writeFile(join(link, "nested"), "tracked\n"); + execFileSync("git", ["add", "link/nested"], { cwd: owner }); - const external = await repository(); - const nested = join(external, "nested"); - await mkdir(nested); - execFileSync("git", ["init", "-q"], { cwd: nested }); - await writeFile( - join(nested, ".git", "config"), - "[include]\n\tpath = outside\n", - ); - await rm(link, { recursive: true }); - await symlink( - external, - link, - process.platform === "win32" ? "junction" : "dir", - ); + const external = await repository(); + const nested = join(external, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile( + join(nested, ".git", "config"), + "[include]\n\tpath = outside\n", + ); + await rm(link, { recursive: true }); + await symlink( + external, + link, + process.platform === "win32" ? "junction" : "dir", + ); - await expect(inventory(checkout)).rejects.toThrow( - "symbolic Git inventory paths are not supported", - ); - }); + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git inventory paths are not supported", + ); + }, + ); test.skipIf(process.platform !== "win32")( "rejects drive-relative Git index entries before probing another drive", @@ -1349,6 +1381,45 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32")( + "excludes case-equivalent Git metadata before running ripgrep", + async () => { + const ripgrep = Bun.which("rg"); + if (ripgrep === null) return; + + const checkout = await repository(); + const metadata = join(checkout, ".GIT"); + await rename(join(checkout, ".git"), metadata); + const equivalent = await realpath(join(checkout, ".git")).then( + async (resolved) => resolved === (await realpath(metadata)), + () => false, + ); + if (!equivalent) return; + + const external = join(dirname(checkout), "external.ignore"); + const trace = join(dirname(checkout), "ripgrep-output"); + const wrappers = join(dirname(checkout), "bin"); + await mkdir(wrappers); + await writeFile(external, "# external ignore rules\n"); + await symlink(external, join(metadata, ".ignore")); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + const wrapper = join(wrappers, "rg"); + await writeFile( + wrapper, + `#!/bin/sh\nif [ "$PWD" = ${JSON.stringify(checkout)} ]; then\n ${JSON.stringify(ripgrep)} "$@" > ${JSON.stringify(trace)}\n status=$?\n cat ${JSON.stringify(trace)}\n exit "$status"\nfi\nexec ${JSON.stringify(ripgrep)} "$@"\n`, + ); + await chmod(wrapper, 0o755); + + expect( + await inventory(checkout, ".", { + ...process.env, + PATH: `${wrappers}:${process.env["PATH"] ?? ""}`, + }), + ).toEqual(["./visible.ts"]); + expect((await readFile(trace)).toString()).not.toContain(".GIT/"); + }, + ); + test.skipIf(process.platform === "win32")( "rejects snapshot ignore links without discovering a parent checkout", async () => { From 7ca1ed348f27be9548231f8c7cf996e0c95382f9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 04:58:37 -0700 Subject: [PATCH 058/106] fix(inventory): preserve unterminated ignore rules --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 9 ++++++--- sdk/typescript/tests-ts/scan-inventory.test.ts | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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 8071a4bf..65f37e08 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -538,8 +538,11 @@ def install_ignore( ) ) rebased = [] - for line in contents.removeprefix(b"\xef\xbb\xbf").split(b"\n"): - line = line.removesuffix(b"\r") + lines = contents.removeprefix(b"\xef\xbb\xbf").split(b"\n") + for index, line in enumerate(lines): + terminated = index < len(lines) - 1 + if terminated: + line = line.removesuffix(b"\r") if not line or line.startswith(b"#"): continue negated = line.startswith(b"!") @@ -556,7 +559,7 @@ def install_ignore( + prefix + b"/" + pattern - + b"\n" + + (b"\n" if terminated else b"") ) contents = b"".join(rebased) position = len(external_ignores) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d0b84e8a..50113caa 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -743,6 +743,7 @@ describe("security scan file inventory", () => { ["slash-only", "/\n"], ["whitespace-only", " \n"], ["embedded-carriage-return", ".IGNORE/tracked.ts\rignored\n"], + ["unterminated-carriage-return", ".IGNORE/tracked.ts\r"], ])( "keeps %s ignores inert when isolating nested checkout names", async (_description, contents) => { From e83ab2ecb7dbbcc4b5b7fdb819c1c00087f42a2c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:08:21 -0700 Subject: [PATCH 059/106] fix(inventory): make Git metadata exclusions authoritative --- .../scripts/generate_in_scope_files.py | 23 +++++++------------ .../tests-ts/scan-inventory.test.ts | 3 ++- 2 files changed, 10 insertions(+), 16 deletions(-) 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 65f37e08..4a97e418 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -344,21 +344,14 @@ def ripgrep_inventory( continue relative_alias = "/".join(re.escape(part) for part in alias[len(directory_parts) :]) if relative_alias and "\n" not in relative_alias and "\r" not in relative_alias: - ignored_aliases.append(f"/{relative_alias}\n") - with tempfile.TemporaryDirectory() as temporary_directory, tempfile.TemporaryFile( - mode="w+b" - ) as inventory: - if ignored_aliases: - alias_file = Path(temporary_directory) / "git-metadata.ignore" - alias_file.write_bytes(b"".join(os.fsencode(alias) for alias in ignored_aliases)) - arguments.extend(["--ignore-file", str(alias_file)]) - if directory_guard: - relative_scope = requested_scope.removeprefix("./") - arguments.extend( - ["--quiet", "--glob", f"/{re.escape(relative_scope)}/**", "--", "."] - ) - else: - arguments.extend(["--", requested_scope]) + ignored_aliases.append(relative_alias) + if directory_guard: + relative_scope = requested_scope.removeprefix("./") + arguments.extend(["--quiet", "--glob", f"/{re.escape(relative_scope)}/**"]) + for alias in ignored_aliases: + arguments.extend(["--glob", f"!/{alias}", "--glob", f"!/{alias}/**"]) + arguments.extend(["--", "." if directory_guard else requested_scope]) + with tempfile.TemporaryFile(mode="w+b") as inventory: try: result = subprocess.run( arguments, diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 50113caa..23b14674 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1403,6 +1403,7 @@ describe("security scan file inventory", () => { await mkdir(wrappers); await writeFile(external, "# external ignore rules\n"); await symlink(external, join(metadata, ".ignore")); + await writeFile(join(checkout, ".rgignore"), "!.GIT/\n!.GIT/**\n"); await writeFile(join(checkout, "visible.ts"), "visible\n"); const wrapper = join(wrappers, "rg"); await writeFile( @@ -1416,7 +1417,7 @@ describe("security scan file inventory", () => { ...process.env, PATH: `${wrappers}:${process.env["PATH"] ?? ""}`, }), - ).toEqual(["./visible.ts"]); + ).toEqual(["./.rgignore", "./visible.ts"]); expect((await readFile(trace)).toString()).not.toContain(".GIT/"); }, ); From afabc7321111fb14e6a7cfa6608234596e5fcda8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:12:51 -0700 Subject: [PATCH 060/106] fix(inventory): verify contained Git worktree ownership --- .../scripts/generate_in_scope_files.py | 29 +++++++++++-- .../tests-ts/scan-inventory.test.ts | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) 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 4a97e418..c1817c16 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import configparser import io import os import re @@ -192,10 +193,8 @@ def inspect_metadata( for _ in range(len(gitdir.parts) - len(repository_parts)): ancestor = ancestor.parent internally_owned = directory_identity(ancestor) == directory_identity(repository) - if not internally_owned: - backpointer = gitdir / "gitdir" - if inspect_metadata(backpointer, directory=False) is None: - raise InventoryError("Git metadata directory does not own selected worktree") + backpointer = gitdir / "gitdir" + if inspect_metadata(backpointer, directory=False) is not None: try: target = Path(os.fsdecode(backpointer.read_bytes().rstrip(b"\r\n"))) except (OSError, ValueError) as error: @@ -204,6 +203,28 @@ def inspect_metadata( target = gitdir / target if Path(os.path.abspath(target)).parts != marker.parts: raise InventoryError("Git metadata directory does not own selected worktree") + elif internally_owned: + config_path = gitdir / "config" + if inspect_metadata(config_path, directory=False) is None: + raise InventoryError("Git metadata directory does not own selected worktree") + config = configparser.ConfigParser(interpolation=None, strict=False) + try: + config.read_string(config_path.read_text(encoding="utf-8-sig")) + configured_worktree = config.get("core", "worktree", fallback=None) + except (OSError, UnicodeError, configparser.Error) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if configured_worktree is None: + raise InventoryError("Git metadata directory does not own selected worktree") + target = Path(configured_worktree) + if not target.is_absolute(): + target = gitdir / target + target = Path(os.path.abspath(target)) + if target.parts != directory.parts or directory_identity(target) != directory_identity( + directory + ): + raise InventoryError("Git metadata directory does not own selected worktree") + else: + raise InventoryError("Git metadata directory does not own selected worktree") else: return False diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 23b14674..c6b615de 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -904,6 +904,23 @@ describe("security scan file inventory", () => { }, ); + test("rejects an internal gitfile that borrows another checkout's index", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + await writeFile(join(checkout, "secret.ts"), "tracked\n"); + execFileSync("git", ["add", "secret.ts"], { cwd: checkout }); + await writeFile(join(nested, ".ignore"), "secret.ts\n"); + await writeFile(join(nested, "secret.ts"), "private\n"); + await writeFile(join(nested, ".git"), "gitdir: ../.git\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + }); + test("rejects unrelated external Git common directories", async () => { if (Bun.which("rg") === null) return; @@ -1260,6 +1277,31 @@ describe("security scan file inventory", () => { expect(await inventory(linked)).toContain("./visible.ts"); }); + test("inventories genuine Git submodules with internal metadata", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const source = await repository(); + await writeFile(join(source, "visible.ts"), "tracked\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: source }); + commit(source); + execFileSync( + "git", + [ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + source, + "nested", + ], + { cwd: checkout }, + ); + + expect(await inventory(checkout)).toContain("./nested/visible.ts"); + }); + test("does not inspect a differently cased checkout outside an explicit scope", async () => { if (Bun.which("rg") === null) return; From 6c3f078c119334b54d2ef48de633f1fd0a0a8544 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:22:19 -0700 Subject: [PATCH 061/106] fix(inventory): prevent duplicate Git worktree claims --- .../scripts/generate_in_scope_files.py | 8 ++++ .../tests-ts/scan-inventory.test.ts | 40 ++++++++++++------- 2 files changed, 34 insertions(+), 14 deletions(-) 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 c1817c16..1985be62 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -142,6 +142,8 @@ def nonsymbolic_directory(path: Path) -> bool: return False return stat.S_ISDIR(metadata.st_mode) and not symbolic_metadata(metadata) + gitdir_owners: dict[tuple[int, int], tuple[int, int]] = {} + def has_git_marker(directory: Path) -> bool: marker = directory / ".git" @@ -228,6 +230,12 @@ def inspect_metadata( else: return False + identity = directory_identity(gitdir) + owner = directory_identity(directory) + if identity in gitdir_owners and gitdir_owners[identity] != owner: + raise InventoryError("Git metadata directory does not own selected worktree") + gitdir_owners[identity] = owner + roots = [gitdir] common_marker = gitdir / "commondir" common_metadata = inspect_metadata(common_marker, directory=False) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index c6b615de..2c0198b5 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -904,22 +904,34 @@ describe("security scan file inventory", () => { }, ); - test("rejects an internal gitfile that borrows another checkout's index", async () => { - if (Bun.which("rg") === null) return; + test.each(["missing", "conflicting"])( + "rejects an internal gitfile with %s checkout ownership", + async (ownership) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const nested = join(checkout, "nested"); - await mkdir(nested); - await writeFile(join(checkout, "secret.ts"), "tracked\n"); - execFileSync("git", ["add", "secret.ts"], { cwd: checkout }); - await writeFile(join(nested, ".ignore"), "secret.ts\n"); - await writeFile(join(nested, "secret.ts"), "private\n"); - await writeFile(join(nested, ".git"), "gitdir: ../.git\n"); + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + await writeFile(join(checkout, "secret.ts"), "tracked\n"); + execFileSync("git", ["add", "secret.ts"], { cwd: checkout }); + await writeFile(join(nested, ".ignore"), "secret.ts\n"); + await writeFile(join(nested, "secret.ts"), "private\n"); + await writeFile(join(nested, ".git"), "gitdir: ../.git\n"); + if (ownership === "conflicting") { + execFileSync( + "git", + ["config", "--local", "core.worktree", "../nested"], + { + cwd: checkout, + }, + ); + } - await expect(inventory(checkout)).rejects.toThrow( - "Git metadata directory does not own selected worktree", - ); - }); + await expect(inventory(checkout)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + }, + ); test("rejects unrelated external Git common directories", async () => { if (Bun.which("rg") === null) return; From 2f492b9048d5f4a29d7ef25e2ab6d39cc35e1d24 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:25:45 -0700 Subject: [PATCH 062/106] fix(inventory): preserve valid ignore paths and Git config --- .../scripts/generate_in_scope_files.py | 24 ++++++----- .../tests-ts/scan-inventory.test.ts | 43 +++++++++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) 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 1985be62..4092ca20 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -125,7 +125,9 @@ def reject_symbolic_ignore(directory: Path) -> None: metadata = (directory / name).stat(follow_symlinks=False) except FileNotFoundError: continue - if not symbolic_metadata(metadata) and stat.S_ISREG(metadata.st_mode): + if not symbolic_metadata(metadata) and ( + stat.S_ISREG(metadata.st_mode) or stat.S_ISDIR(metadata.st_mode) + ): continue if symbolic_metadata(metadata): raise InventoryError("symbolic ignore files are not supported") @@ -135,6 +137,11 @@ def directory_identity(path: Path) -> tuple[int, int]: metadata = path.stat() return metadata.st_dev, metadata.st_ino + def same_filesystem_path(first: Path, second: Path) -> bool: + return tuple(unicodedata.normalize("NFC", part).casefold() for part in first.parts) == tuple( + unicodedata.normalize("NFC", part).casefold() for part in second.parts + ) and directory_identity(first) == directory_identity(second) + def nonsymbolic_directory(path: Path) -> bool: try: metadata = path.stat(follow_symlinks=False) @@ -203,13 +210,15 @@ def inspect_metadata( raise InventoryError(f"could not inspect Git metadata: {directory}") from error if not target.is_absolute(): target = gitdir / target - if Path(os.path.abspath(target)).parts != marker.parts: + if not same_filesystem_path(Path(os.path.abspath(target)), marker): raise InventoryError("Git metadata directory does not own selected worktree") elif internally_owned: config_path = gitdir / "config" if inspect_metadata(config_path, directory=False) is None: raise InventoryError("Git metadata directory does not own selected worktree") - config = configparser.ConfigParser(interpolation=None, strict=False) + config = configparser.ConfigParser( + interpolation=None, strict=False, allow_no_value=True + ) try: config.read_string(config_path.read_text(encoding="utf-8-sig")) configured_worktree = config.get("core", "worktree", fallback=None) @@ -221,9 +230,7 @@ def inspect_metadata( if not target.is_absolute(): target = gitdir / target target = Path(os.path.abspath(target)) - if target.parts != directory.parts or directory_identity(target) != directory_identity( - directory - ): + if not same_filesystem_path(target, directory): raise InventoryError("Git metadata directory does not own selected worktree") else: raise InventoryError("Git metadata directory does not own selected worktree") @@ -248,10 +255,7 @@ def inspect_metadata( common = gitdir / common common = Path(os.path.abspath(common)) owner = common / "worktrees" / gitdir.name - equivalent = tuple( - unicodedata.normalize("NFC", part).casefold() for part in owner.parts - ) == tuple(unicodedata.normalize("NFC", part).casefold() for part in gitdir.parts) - if not equivalent or directory_identity(owner) != directory_identity(gitdir): + if not same_filesystem_path(owner, gitdir): raise InventoryError("Git common directory does not own selected worktree") for current in reversed((common, *common.parents)): if inspect_metadata(current, directory=True) is None: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 2c0198b5..db4261f9 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -113,6 +113,20 @@ describe("security scan file inventory", () => { ]); }); + test.each([".gitignore", ".ignore", ".rgignore"])( + "inventories ordinary directories named %s", + async (name) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const directory = join(checkout, name); + await mkdir(directory); + await writeFile(join(directory, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain(`./${name}/visible.ts`); + }, + ); + test.each([".ignore", ".rgignore"])( "keeps ordinary files re-included by higher-precedence %s rules", async (override) => { @@ -1275,15 +1289,29 @@ describe("security scan file inventory", () => { expect(await inventory(linked)).toContain("./visible.ts"); + const gitdir = (await readFile(join(linked, ".git"), "utf8")) + .replace(/^gitdir: /, "") + .trim(); + const alternateBackpointer = join( + dirname(linked), + "LINKED-WORKTREE", + ".git", + ); + const equivalentBackpointer = await realpath(alternateBackpointer).then( + async (resolved) => resolved === (await realpath(join(linked, ".git"))), + () => false, + ); + if (equivalentBackpointer) { + await writeFile(join(gitdir, "gitdir"), `${alternateBackpointer}\n`); + expect(await inventory(linked)).toContain("./visible.ts"); + } + const aliased = join(dirname(checkout), "REPOSITORY", ".git"); const equivalent = await realpath(aliased).then( async (resolved) => resolved === (await realpath(join(checkout, ".git"))), () => false, ); if (!equivalent) return; - const gitdir = (await readFile(join(linked, ".git"), "utf8")) - .replace(/^gitdir: /, "") - .trim(); await writeFile(join(gitdir, "commondir"), `${aliased}\n`); expect(await inventory(linked)).toContain("./visible.ts"); @@ -1310,6 +1338,15 @@ describe("security scan file inventory", () => { ], { cwd: checkout }, ); + const gitdir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: join(checkout, "nested"), + encoding: "utf8", + }).trim(); + const config = join(gitdir, "config"); + await writeFile( + config, + `${await readFile(config, "utf8")}\n[feature]\n\tenabled\n`, + ); expect(await inventory(checkout)).toContain("./nested/visible.ts"); }); From f7200b0419c523d55425db828e5b2ceda6bd8e24 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:33:34 -0700 Subject: [PATCH 063/106] fix(inventory): verify worktree marker parent identity --- .../scripts/generate_in_scope_files.py | 4 ++- .../tests-ts/scan-inventory.test.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 4092ca20..15827103 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -140,7 +140,9 @@ def directory_identity(path: Path) -> tuple[int, int]: def same_filesystem_path(first: Path, second: Path) -> bool: return tuple(unicodedata.normalize("NFC", part).casefold() for part in first.parts) == tuple( unicodedata.normalize("NFC", part).casefold() for part in second.parts - ) and directory_identity(first) == directory_identity(second) + ) and directory_identity(first) == directory_identity(second) and directory_identity( + first.parent + ) == directory_identity(second.parent) def nonsymbolic_directory(path: Path) -> bool: try: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index db4261f9..fa3178e4 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmod, + link as hardlink, mkdir, mkdtemp, readdir, @@ -1317,6 +1318,36 @@ describe("security scan file inventory", () => { expect(await inventory(linked)).toContain("./visible.ts"); }); + test("rejects worktree backpointers hard-linked through equivalent sibling names", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const source = await repository(); + const hidden = join(checkout, "ß"); + const visible = join(checkout, "ss"); + await writeFile(join(source, "secret.ts"), "tracked\n"); + execFileSync("git", ["add", "secret.ts"], { cwd: source }); + commit(source); + execFileSync("git", ["worktree", "add", "--detach", hidden, "HEAD"], { + cwd: source, + stdio: "ignore", + }); + try { + await mkdir(visible); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + await writeFile(join(checkout, ".ignore"), "ß/\n"); + await writeFile(join(visible, ".ignore"), "secret.ts\n"); + await writeFile(join(visible, "secret.ts"), "private\n"); + await hardlink(join(hidden, ".git"), join(visible, ".git")); + + await expect(inventory(checkout)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + }); + test("inventories genuine Git submodules with internal metadata", async () => { if (Bun.which("rg") === null) return; From 8330a5fc079ebcff8fe8dcadc276132ea9c289a0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:37:53 -0700 Subject: [PATCH 064/106] fix(inventory): validate transitive Git object alternates --- .../scripts/generate_in_scope_files.py | 77 ++++++++++++------- .../tests-ts/scan-inventory.test.ts | 45 +++++++++++ 2 files changed, 94 insertions(+), 28 deletions(-) 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 15827103..1be55ad1 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -302,37 +302,58 @@ def inspect_metadata( except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if relative == "objects/info/alternates": - for line in contents.split(b"\n"): - line = line.removesuffix(b"\r") - if not line: - continue - alternate = Path(os.fsdecode(line)) - if not alternate.is_absolute(): - alternate = root / "objects" / alternate - alternate = Path(os.path.abspath(alternate)) - owner = next( - ( - candidate - for candidate in (repository, *roots) - if alternate.parts[: len(candidate.parts)] == candidate.parts - ), - None, - ) - anchor = alternate - if owner is not None: - for _ in range(len(alternate.parts) - len(owner.parts)): - anchor = anchor.parent - if owner is None or directory_identity(anchor) != directory_identity(owner): - raise InventoryError( - "external Git object alternates are not supported" + pending = [(root / "objects", contents)] + inspected = {directory_identity(root / "objects")} + while pending: + object_root, records = pending.pop() + for line in records.split(b"\n"): + line = line.removesuffix(b"\r") + if not line: + continue + alternate = Path(os.fsdecode(line)) + if not alternate.is_absolute(): + alternate = object_root / alternate + alternate = Path(os.path.abspath(alternate)) + owner = next( + ( + candidate + for candidate in (repository, *roots) + if alternate.parts[: len(candidate.parts)] == candidate.parts + ), + None, ) - current = owner - for component in alternate.relative_to(owner).parts: - current /= component - if inspect_metadata(current, directory=True) is None: + anchor = alternate + if owner is not None: + for _ in range(len(alternate.parts) - len(owner.parts)): + anchor = anchor.parent + if owner is None or directory_identity(anchor) != directory_identity(owner): raise InventoryError( - "missing Git object alternates are not supported" + "external Git object alternates are not supported" ) + current = owner + for component in alternate.relative_to(owner).parts: + current /= component + if inspect_metadata(current, directory=True) is None: + raise InventoryError( + "missing Git object alternates are not supported" + ) + identity = directory_identity(alternate) + if identity in inspected: + continue + inspected.add(identity) + info = alternate / "info" + if inspect_metadata(info, directory=True) is None: + continue + nested_alternates = info / "alternates" + if inspect_metadata(nested_alternates, directory=False) is None: + continue + try: + records = nested_alternates.read_bytes() + except OSError as error: + raise InventoryError( + f"could not inspect Git metadata: {directory}" + ) from error + pending.append((alternate, records)) elif GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): raise InventoryError("Git config includes are not supported") try: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index fa3178e4..70500e13 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1027,6 +1027,30 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test("rejects external transitive Git object alternates before invoking Git", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const internal = join(checkout, ".git", "extra-objects"); + const trace = join(dirname(checkout), "git-trace.log"); + await mkdir(join(internal, "info"), { recursive: true }); + await mkdir(join(internal, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${internal}\n`, + ); + await writeFile( + join(internal, "info", "alternates"), + `${join(external, ".git", "objects")}\n`, + ); + + await expect( + inventory(checkout, ".", { ...process.env, GIT_TRACE: trace }), + ).rejects.toThrow("external Git object alternates are not supported"); + await expect(readFile(trace, "utf8")).rejects.toThrow(); + }); + test("rejects differently cased sibling Git object alternates", async () => { if (Bun.which("rg") === null) return; @@ -1065,6 +1089,27 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); + test("allows repository-owned transitive Git object alternates", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const first = join(checkout, ".git", "first-objects"); + const second = join(checkout, ".git", "second-objects"); + for (const objects of [first, second]) { + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + } + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${first}\n`, + ); + await writeFile(join(first, "info", "alternates"), `${second}\n`); + await writeFile(join(second, "info", "alternates"), `${first}\n`); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }); + test.skipIf(process.platform === "win32")( "rejects escaped Git index entries before probing sibling metadata", async () => { From 1e157718ad6010d1a207a53dd24ef10af8d294ac Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:46:28 -0700 Subject: [PATCH 065/106] fix(inventory): validate native Git object-store paths --- .../scripts/generate_in_scope_files.py | 25 ++++++++- .../tests-ts/scan-inventory.test.ts | 56 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) 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 1be55ad1..9c73e495 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import codecs import configparser import io import os @@ -173,6 +174,17 @@ def inspect_metadata( raise InventoryError("non-regular Git metadata files are not supported") return metadata + def inspect_object_store(objects: Path) -> None: + try: + entries = objects.iterdir() + for entry in entries: + if entry.name.casefold() in ("info", "pack") or re.fullmatch( + r"[0-9a-fA-F]{2}", entry.name + ): + inspect_metadata(entry, directory=True) + except OSError as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + try: metadata = marker.stat(follow_symlinks=False) except FileNotFoundError: @@ -292,6 +304,8 @@ def inspect_metadata( "objects/info", ), ) + if metadata is not None and relative == "objects": + inspect_object_store(path) if metadata is not None and relative in ( "config", "config.worktree", @@ -307,9 +321,17 @@ def inspect_metadata( while pending: object_root, records = pending.pop() for line in records.split(b"\n"): - line = line.removesuffix(b"\r") if not line: continue + if line.startswith(b'"'): + if not line.endswith(b'"'): + raise InventoryError("invalid Git object alternate paths") + try: + line = codecs.escape_decode(line[1:-1])[0] + except (ValueError, UnicodeError) as error: + raise InventoryError( + "invalid Git object alternate paths" + ) from error alternate = Path(os.fsdecode(line)) if not alternate.is_absolute(): alternate = object_root / alternate @@ -341,6 +363,7 @@ def inspect_metadata( if identity in inspected: continue inspected.add(identity) + inspect_object_store(alternate) info = alternate / "info" if inspect_metadata(info, directory=True) is None: continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 70500e13..0e18f19c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1027,6 +1027,55 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test.each(["pack", "ab"])( + "rejects symbolic Git object-store %s directories", + async (name) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const internal = join(checkout, ".git", "extra-objects"); + const target = join(external, ".git", "objects", name); + await mkdir(join(internal, "info"), { recursive: true }); + if (name !== "pack") await mkdir(target); + await symlink( + target, + join(internal, name), + process.platform === "win32" ? "junction" : "dir", + ); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${internal}\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + + test.skipIf(process.platform === "win32")( + "preserves carriage returns in Git object-alternate paths", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const internal = join(checkout, ".git", "safe-objects"); + await mkdir(join(internal, "info"), { recursive: true }); + await mkdir(join(internal, "pack")); + await symlink(join(external, ".git", "objects"), `${internal}\r`); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${internal}\r\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test("rejects external transitive Git object alternates before invoking Git", async () => { if (Bun.which("rg") === null) return; @@ -1082,7 +1131,7 @@ describe("security scan file inventory", () => { await mkdir(join(objects, "pack")); await writeFile( join(checkout, ".git", "objects", "info", "alternates"), - `${objects}\n`, + `${JSON.stringify(objects)}\n`, ); await writeFile(join(checkout, "visible.ts"), "visible\n"); @@ -1103,7 +1152,10 @@ describe("security scan file inventory", () => { join(checkout, ".git", "objects", "info", "alternates"), `${first}\n`, ); - await writeFile(join(first, "info", "alternates"), `${second}\n`); + await writeFile( + join(first, "info", "alternates"), + `${JSON.stringify(second)}\n`, + ); await writeFile(join(second, "info", "alternates"), `${first}\n`); await writeFile(join(checkout, "visible.ts"), "visible\n"); From efd3e7396f2354a1a68fc752655488b0479ea7f2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 05:57:57 -0700 Subject: [PATCH 066/106] fix(inventory): prevent unsafe Git object reads and fetches --- .../scripts/generate_in_scope_files.py | 29 +++++- .../tests-ts/scan-inventory.test.ts | 93 ++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) 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 9c73e495..dfdafa8d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -178,10 +178,23 @@ def inspect_object_store(objects: Path) -> None: try: entries = objects.iterdir() for entry in entries: - if entry.name.casefold() in ("info", "pack") or re.fullmatch( - r"[0-9a-fA-F]{2}", entry.name + canonical = entry.name.casefold() + if canonical not in ("info", "pack") and not re.fullmatch( + r"[0-9a-f]{2}", canonical ): - inspect_metadata(entry, directory=True) + continue + if entry.name != canonical: + try: + actual = entry.stat(follow_symlinks=False) + expected = (objects / canonical).stat(follow_symlinks=False) + except FileNotFoundError: + continue + if (actual.st_dev, actual.st_ino) != (expected.st_dev, expected.st_ino): + continue + inspect_metadata(entry, directory=True) + if canonical != "info": + for member in entry.iterdir(): + inspect_metadata(member, directory=False) except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error @@ -240,6 +253,15 @@ def inspect_object_store(objects: Path) -> None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if configured_worktree is None: raise InventoryError("Git metadata directory does not own selected worktree") + if configured_worktree.startswith('"'): + if not configured_worktree.endswith('"'): + raise InventoryError("invalid Git worktree path") + try: + configured_worktree = os.fsdecode( + codecs.escape_decode(os.fsencode(configured_worktree[1:-1]))[0] + ) + except (ValueError, UnicodeError) as error: + raise InventoryError("invalid Git worktree path") from error target = Path(configured_worktree) if not target.is_absolute(): target = gitdir / target @@ -742,6 +764,7 @@ def admit_gitlink_directories(directory: Path, contents: bytes) -> bytes: ): environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" + environment["GIT_NO_LAZY_FETCH"] = "1" environment["LC_ALL"] = "C" git = [ "git", diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 0e18f19c..dd155ecd 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1054,6 +1054,60 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32").each([ + ["primary", "pack"], + ["primary", "ab"], + ["alternate", "pack"], + ["alternate", "ab"], + ])("rejects symbolic %s Git object files in %s", async (owner, kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = join(dirname(checkout), "external-object"); + await writeFile(external, "external\n"); + const objects = + owner === "primary" + ? join(checkout, ".git", "objects") + : join(checkout, ".git", "extra-objects"); + if (owner === "alternate") { + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${objects}\n`, + ); + } + const directory = join(objects, kind); + if (kind !== "pack") await mkdir(directory); + const member = kind === "pack" ? "pack-external.pack" : "0".repeat(38); + await symlink(external, join(directory, member)); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }); + + test.skipIf(process.platform === "win32").each(["PACK", "AB"])( + "ignores unrelated uppercase Git object-store name %s", + async (name) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const objects = join(checkout, ".git", "objects"); + if (name === "AB") await mkdir(join(objects, "ab")); + try { + await symlink(join(external, ".git", "objects"), join(objects, name)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return; + throw error; + } + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); + test.skipIf(process.platform === "win32")( "preserves carriage returns in Git object-alternate paths", async () => { @@ -1191,6 +1245,38 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32")( + "disables lazy Git object fetching during inventory", + async () => { + const git = Bun.which("git"); + if (Bun.which("rg") === null || git === null) return; + + const checkout = await repository(); + const wrappers = join(dirname(checkout), "bin"); + const trace = join(dirname(checkout), "lazy-fetch.log"); + await mkdir(wrappers); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + const wrapper = join(wrappers, "git"); + await writeFile( + wrapper, + `#!/bin/sh\nprintf '%s\\n' "$GIT_NO_LAZY_FETCH" >> ${JSON.stringify(trace)}\nexec ${JSON.stringify(git)} "$@"\n`, + ); + await chmod(wrapper, 0o755); + + expect( + await inventory(checkout, ".", { + ...process.env, + GIT_NO_LAZY_FETCH: "0", + PATH: `${wrappers}:${process.env["PATH"] ?? ""}`, + }), + ).toContain("./visible.ts"); + expect((await readFile(trace, "utf8")).trim().split("\n")).toSatisfy( + (values: string[]) => + values.length > 0 && values.every((value) => value === "1"), + ); + }, + ); + test.each(["selected", "nested"])( "rejects symbolic ancestors in %s Git-listed paths before reading external metadata", async (kind) => { @@ -1471,10 +1557,11 @@ describe("security scan file inventory", () => { encoding: "utf8", }).trim(); const config = join(gitdir, "config"); - await writeFile( - config, - `${await readFile(config, "utf8")}\n[feature]\n\tenabled\n`, + const configured = (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=[ \t]*)(.+)$/m, + '$1"$2"', ); + await writeFile(config, `${configured}\n[feature]\n\tenabled\n`); expect(await inventory(checkout)).toContain("./nested/visible.ts"); }); From 2b31a71da95a4226365a219d0ec910ed3c4011d6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:10:30 -0700 Subject: [PATCH 067/106] fix(inventory): cache Git metadata and honor native config --- .../scripts/generate_in_scope_files.py | 32 +++++++++++- .../tests-ts/scan-inventory.test.ts | 51 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) 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 dfdafa8d..a80ac434 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -153,6 +153,7 @@ def nonsymbolic_directory(path: Path) -> bool: return stat.S_ISDIR(metadata.st_mode) and not symbolic_metadata(metadata) gitdir_owners: dict[tuple[int, int], tuple[int, int]] = {} + validated_object_stores: set[tuple[int, int]] = set() def has_git_marker(directory: Path) -> bool: marker = directory / ".git" @@ -176,6 +177,9 @@ def inspect_metadata( def inspect_object_store(objects: Path) -> None: try: + identity = directory_identity(objects) + if identity in validated_object_stores: + return entries = objects.iterdir() for entry in entries: canonical = entry.name.casefold() @@ -194,7 +198,17 @@ def inspect_object_store(objects: Path) -> None: inspect_metadata(entry, directory=True) if canonical != "info": for member in entry.iterdir(): + if canonical == "pack": + if member.name != "multi-pack-index" and not re.fullmatch( + r"(?:pack|multi-pack-index)-[0-9a-f]{40}(?:[0-9a-f]{24})?" + r"\.(?:pack|idx|rev|bitmap|keep|promisor|mtimes)", + member.name, + ): + continue + elif not re.fullmatch(r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member.name): + continue inspect_metadata(member, directory=False) + validated_object_stores.add(identity) except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error @@ -253,6 +267,18 @@ def inspect_object_store(objects: Path) -> None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if configured_worktree is None: raise InventoryError("Git metadata directory does not own selected worktree") + quoted = False + escaped = False + for index, character in enumerate(configured_worktree): + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quoted = not quoted + elif character in "#;" and not quoted: + configured_worktree = configured_worktree[:index].rstrip() + break if configured_worktree.startswith('"'): if not configured_worktree.endswith('"'): raise InventoryError("invalid Git worktree path") @@ -275,8 +301,10 @@ def inspect_object_store(objects: Path) -> None: identity = directory_identity(gitdir) owner = directory_identity(directory) - if identity in gitdir_owners and gitdir_owners[identity] != owner: - raise InventoryError("Git metadata directory does not own selected worktree") + if identity in gitdir_owners: + if gitdir_owners[identity] != owner: + raise InventoryError("Git metadata directory does not own selected worktree") + return True gitdir_owners[identity] = owner roots = [gitdir] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index dd155ecd..9749cc01 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -165,6 +165,37 @@ describe("security scan file inventory", () => { expect(await countLookups(18)).toBeLessThanOrEqual(await countLookups(2)); }); + test("validates Git object files once across many scan directories", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const instrumentation = join(dirname(checkout), "instrumentation"); + const trace = join(dirname(checkout), "object-stats.log"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + "import os\nfrom pathlib import Path\noriginal = Path.stat\ndef observed(self, *args, **kwargs):\n if self.parent.parent.name == 'objects' and len(self.parent.name) == 2 and len(self.name) == 38:\n with open(os.environ['INVENTORY_OBJECT_STAT_TRACE'], 'a') as trace:\n trace.write(str(self) + '\\n')\n return original(self, *args, **kwargs)\nPath.stat = observed\n", + ); + for (let index = 0; index < 5; index++) { + execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: checkout, + input: `object-${index}\n`, + }); + } + for (let index = 0; index < 10; index++) { + const branch = join(checkout, `branch-${index}`, "nested"); + await mkdir(branch, { recursive: true }); + await writeFile(join(branch, "visible.ts"), "visible\n"); + } + + await inventory(checkout, ".", { + ...process.env, + PYTHONPATH: instrumentation, + INVENTORY_OBJECT_STAT_TRACE: trace, + }); + expect((await readFile(trace, "utf8")).trim().split("\n")).toHaveLength(5); + }); + test.each([".ignore", ".rgignore"])( "preserves ancestor %s precedence for explicit directory scopes", async (override) => { @@ -1079,7 +1110,8 @@ describe("security scan file inventory", () => { } const directory = join(objects, kind); if (kind !== "pack") await mkdir(directory); - const member = kind === "pack" ? "pack-external.pack" : "0".repeat(38); + const member = + kind === "pack" ? `pack-${"0".repeat(40)}.pack` : "0".repeat(38); await symlink(external, join(directory, member)); await expect(inventory(checkout)).rejects.toThrow( @@ -1087,6 +1119,21 @@ describe("security scan file inventory", () => { ); }); + test.each(["pack", "ab"])( + "allows unrelated tooling directories inside Git object %s", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const directory = join(checkout, ".git", "objects", kind); + if (kind !== "pack") await mkdir(directory); + await mkdir(join(directory, "unrelated-dir")); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); + test.skipIf(process.platform === "win32").each(["PACK", "AB"])( "ignores unrelated uppercase Git object-store name %s", async (name) => { @@ -1559,7 +1606,7 @@ describe("security scan file inventory", () => { const config = join(gitdir, "config"); const configured = (await readFile(config, "utf8")).replace( /^([ \t]*worktree[ \t]*=[ \t]*)(.+)$/m, - '$1"$2"', + '$1"$2" # valid Git comment', ); await writeFile(config, `${configured}\n[feature]\n\tenabled\n`); From 47d6de26b34d58ec70adeb6a995b102f0265a75f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:22:14 -0700 Subject: [PATCH 068/106] fix(inventory): validate Git pack aliases and replacement refs --- .../scripts/generate_in_scope_files.py | 17 +++++++- .../tests-ts/scan-inventory.test.ts | 43 ++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) 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 a80ac434..d9819229 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -200,7 +200,8 @@ def inspect_object_store(objects: Path) -> None: for member in entry.iterdir(): if canonical == "pack": if member.name != "multi-pack-index" and not re.fullmatch( - r"(?:pack|multi-pack-index)-[0-9a-f]{40}(?:[0-9a-f]{24})?" + r"(?:pack|multi-pack-index)-[0-9a-fA-F]{40}" + r"(?:[0-9a-fA-F]{24})?" r"\.(?:pack|idx|rev|bitmap|keep|promisor|mtimes)", member.name, ): @@ -332,6 +333,7 @@ def inspect_object_store(objects: Path) -> None: "packed-refs", "refs", "refs/heads", + "refs/replace", "refs/tags", "config", "config.worktree", @@ -348,6 +350,7 @@ def inspect_object_store(objects: Path) -> None: directory=relative in ( "refs", "refs/heads", + "refs/replace", "refs/tags", "info", "objects", @@ -356,6 +359,17 @@ def inspect_object_store(objects: Path) -> None: ) if metadata is not None and relative == "objects": inspect_object_store(path) + if metadata is not None and relative == "refs/replace": + try: + for reference in path.iterdir(): + if re.fullmatch( + r"[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?", reference.name + ): + inspect_metadata(reference, directory=False) + except OSError as error: + raise InventoryError( + f"could not inspect Git metadata: {directory}" + ) from error if metadata is not None and relative in ( "config", "config.worktree", @@ -793,6 +807,7 @@ def admit_gitlink_directories(directory: Path, contents: bytes) -> bytes: environment.pop(name, None) environment["GIT_LITERAL_PATHSPECS"] = "1" environment["GIT_NO_LAZY_FETCH"] = "1" + environment["GIT_NO_REPLACE_OBJECTS"] = "1" environment["LC_ALL"] = "C" git = [ "git", diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 9749cc01..fa304d7f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1087,8 +1087,10 @@ describe("security scan file inventory", () => { test.skipIf(process.platform === "win32").each([ ["primary", "pack"], + ["primary-uppercase", "pack"], ["primary", "ab"], ["alternate", "pack"], + ["alternate-uppercase", "pack"], ["alternate", "ab"], ])("rejects symbolic %s Git object files in %s", async (owner, kind) => { if (Bun.which("rg") === null) return; @@ -1096,11 +1098,10 @@ describe("security scan file inventory", () => { const checkout = await repository(); const external = join(dirname(checkout), "external-object"); await writeFile(external, "external\n"); - const objects = - owner === "primary" - ? join(checkout, ".git", "objects") - : join(checkout, ".git", "extra-objects"); - if (owner === "alternate") { + const objects = owner.startsWith("primary") + ? join(checkout, ".git", "objects") + : join(checkout, ".git", "extra-objects"); + if (owner.startsWith("alternate")) { await mkdir(join(objects, "info"), { recursive: true }); await mkdir(join(objects, "pack")); await writeFile( @@ -1110,8 +1111,9 @@ describe("security scan file inventory", () => { } const directory = join(objects, kind); if (kind !== "pack") await mkdir(directory); + const hex = owner.endsWith("uppercase") ? "A" : "0"; const member = - kind === "pack" ? `pack-${"0".repeat(40)}.pack` : "0".repeat(38); + kind === "pack" ? `pack-${hex.repeat(40)}.pack` : hex.repeat(38); await symlink(external, join(directory, member)); await expect(inventory(checkout)).rejects.toThrow( @@ -1293,7 +1295,7 @@ describe("security scan file inventory", () => { ); test.skipIf(process.platform === "win32")( - "disables lazy Git object fetching during inventory", + "disables lazy Git object fetching and replacement during inventory", async () => { const git = Bun.which("git"); if (Bun.which("rg") === null || git === null) return; @@ -1306,7 +1308,7 @@ describe("security scan file inventory", () => { const wrapper = join(wrappers, "git"); await writeFile( wrapper, - `#!/bin/sh\nprintf '%s\\n' "$GIT_NO_LAZY_FETCH" >> ${JSON.stringify(trace)}\nexec ${JSON.stringify(git)} "$@"\n`, + `#!/bin/sh\nprintf '%s:%s\\n' "$GIT_NO_LAZY_FETCH" "$GIT_NO_REPLACE_OBJECTS" >> ${JSON.stringify(trace)}\nexec ${JSON.stringify(git)} "$@"\n`, ); await chmod(wrapper, 0o755); @@ -1314,12 +1316,13 @@ describe("security scan file inventory", () => { await inventory(checkout, ".", { ...process.env, GIT_NO_LAZY_FETCH: "0", + GIT_NO_REPLACE_OBJECTS: "0", PATH: `${wrappers}:${process.env["PATH"] ?? ""}`, }), ).toContain("./visible.ts"); expect((await readFile(trace, "utf8")).trim().split("\n")).toSatisfy( (values: string[]) => - values.length > 0 && values.every((value) => value === "1"), + values.length > 0 && values.every((value) => value === "1:1"), ); }, ); @@ -1483,6 +1486,7 @@ describe("security scan file inventory", () => { "packed-refs", "refs", "refs/heads", + "refs/replace", ])("rejects a symbolic Git metadata %s", async (relative) => { if (Bun.which("rg") === null) return; @@ -1491,7 +1495,7 @@ describe("security scan file inventory", () => { await writeFile(join(external, "source.ts"), "tracked\n"); execFileSync("git", ["add", "source.ts"], { cwd: external }); const metadata = join(checkout, ".git", relative); - const directory = relative === "refs" || relative === "refs/heads"; + const directory = relative === "refs" || relative.startsWith("refs/"); const target = join(external, ".git", relative); if (relative === "info/sparse-checkout" || relative === "packed-refs") { await writeFile(target, "external\n"); @@ -1505,6 +1509,25 @@ describe("security scan file inventory", () => { ); }); + test.skipIf(process.platform === "win32")( + "rejects symbolic Git object replacement refs", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const replacement = join(checkout, ".git", "refs", "replace"); + const external = join(dirname(checkout), "external-replacement"); + await mkdir(replacement); + await writeFile(external, `${"0".repeat(40)}\n`); + await symlink(external, join(replacement, "a".repeat(40))); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test("inventories linked worktrees with regular Git metadata", async () => { if (Bun.which("rg") === null) return; From a23c69b38378aeaa04a3e126dfa5f71dd53975e7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:29:30 -0700 Subject: [PATCH 069/106] fix(inventory): validate arbitrary Git pack basenames --- .../scripts/generate_in_scope_files.py | 7 ++----- sdk/typescript/tests-ts/scan-inventory.test.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) 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 d9819229..407f7724 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -199,11 +199,8 @@ def inspect_object_store(objects: Path) -> None: if canonical != "info": for member in entry.iterdir(): if canonical == "pack": - if member.name != "multi-pack-index" and not re.fullmatch( - r"(?:pack|multi-pack-index)-[0-9a-fA-F]{40}" - r"(?:[0-9a-fA-F]{24})?" - r"\.(?:pack|idx|rev|bitmap|keep|promisor|mtimes)", - member.name, + if member.name != "multi-pack-index" and not member.name.endswith( + (".pack", ".idx", ".rev", ".bitmap", ".keep", ".promisor", ".mtimes") ): continue elif not re.fullmatch(r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member.name): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index fa304d7f..266154e0 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1088,9 +1088,13 @@ describe("security scan file inventory", () => { test.skipIf(process.platform === "win32").each([ ["primary", "pack"], ["primary-uppercase", "pack"], + ["primary-arbitrary-pack", "pack"], + ["primary-arbitrary-index", "pack"], ["primary", "ab"], ["alternate", "pack"], ["alternate-uppercase", "pack"], + ["alternate-arbitrary-pack", "pack"], + ["alternate-arbitrary-index", "pack"], ["alternate", "ab"], ])("rejects symbolic %s Git object files in %s", async (owner, kind) => { if (Bun.which("rg") === null) return; @@ -1112,8 +1116,11 @@ describe("security scan file inventory", () => { const directory = join(objects, kind); if (kind !== "pack") await mkdir(directory); const hex = owner.endsWith("uppercase") ? "A" : "0"; - const member = - kind === "pack" ? `pack-${hex.repeat(40)}.pack` : hex.repeat(38); + const basename = owner.includes("arbitrary") + ? "arbitrary" + : `pack-${hex.repeat(40)}`; + const suffix = owner.endsWith("index") ? "idx" : "pack"; + const member = kind === "pack" ? `${basename}.${suffix}` : hex.repeat(38); await symlink(external, join(directory, member)); await expect(inventory(checkout)).rejects.toThrow( From 90d93a16bbd94b7cfaff8ff9665cea830ddf7847 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:34:06 -0700 Subject: [PATCH 070/106] fix(inventory): preserve Git filesystem aliases and path bytes --- .../scripts/generate_in_scope_files.py | 34 ++++++++---- .../tests-ts/scan-inventory.test.ts | 54 ++++++++++++++++++- 2 files changed, 76 insertions(+), 12 deletions(-) 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 407f7724..79d3cf43 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -176,6 +176,14 @@ def inspect_metadata( return metadata def inspect_object_store(objects: Path) -> None: + def aliases_canonical_path(path: Path, canonical: str) -> bool: + try: + actual = path.stat(follow_symlinks=False) + expected = (path.parent / canonical).stat(follow_symlinks=False) + except FileNotFoundError: + return False + return (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino) + try: identity = directory_identity(objects) if identity in validated_object_stores: @@ -187,14 +195,8 @@ def inspect_object_store(objects: Path) -> None: r"[0-9a-f]{2}", canonical ): continue - if entry.name != canonical: - try: - actual = entry.stat(follow_symlinks=False) - expected = (objects / canonical).stat(follow_symlinks=False) - except FileNotFoundError: - continue - if (actual.st_dev, actual.st_ino) != (expected.st_dev, expected.st_ino): - continue + if entry.name != canonical and not aliases_canonical_path(entry, canonical): + continue inspect_metadata(entry, directory=True) if canonical != "info": for member in entry.iterdir(): @@ -203,8 +205,16 @@ def inspect_object_store(objects: Path) -> None: (".pack", ".idx", ".rev", ".bitmap", ".keep", ".promisor", ".mtimes") ): continue - elif not re.fullmatch(r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member.name): - continue + else: + member_canonical = member.name.casefold() + if not re.fullmatch( + r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member_canonical + ): + continue + if member.name != member_canonical and not aliases_canonical_path( + member, member_canonical + ): + continue inspect_metadata(member, directory=False) validated_object_stores.add(identity) except OSError as error: @@ -259,7 +269,9 @@ def inspect_object_store(objects: Path) -> None: interpolation=None, strict=False, allow_no_value=True ) try: - config.read_string(config_path.read_text(encoding="utf-8-sig")) + config.read_string( + os.fsdecode(config_path.read_bytes().removeprefix(codecs.BOM_UTF8)) + ) configured_worktree = config.get("core", "worktree", fallback=None) except (OSError, UnicodeError, configparser.Error) as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 266154e0..6a11afcb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1091,11 +1091,13 @@ describe("security scan file inventory", () => { ["primary-arbitrary-pack", "pack"], ["primary-arbitrary-index", "pack"], ["primary", "ab"], + ["primary-uppercase", "ab"], ["alternate", "pack"], ["alternate-uppercase", "pack"], ["alternate-arbitrary-pack", "pack"], ["alternate-arbitrary-index", "pack"], ["alternate", "ab"], + ["alternate-uppercase", "ab"], ])("rejects symbolic %s Git object files in %s", async (owner, kind) => { if (Bun.which("rg") === null) return; @@ -1122,6 +1124,17 @@ describe("security scan file inventory", () => { const suffix = owner.endsWith("index") ? "idx" : "pack"; const member = kind === "pack" ? `${basename}.${suffix}` : hex.repeat(38); await symlink(external, join(directory, member)); + if (kind !== "pack" && hex === "A") { + const aliases = await realpath(join(directory, "a".repeat(38))).then( + () => true, + () => false, + ); + if (!aliases) { + await writeFile(join(checkout, "visible.ts"), "visible\n"); + expect(await inventory(checkout)).toContain("./visible.ts"); + return; + } + } await expect(inventory(checkout)).rejects.toThrow( "symbolic Git metadata paths are not supported", @@ -1638,11 +1651,50 @@ describe("security scan file inventory", () => { /^([ \t]*worktree[ \t]*=[ \t]*)(.+)$/m, '$1"$2" # valid Git comment', ); - await writeFile(config, `${configured}\n[feature]\n\tenabled\n`); + const suffix = + process.platform === "win32" + ? Buffer.alloc(0) + : Buffer.from([0x23, 0x20, 0xff, 0x0a]); + await writeFile( + config, + Buffer.concat([ + Buffer.from(`${configured}\n[feature]\n\tenabled\n`), + suffix, + ]), + ); expect(await inventory(checkout)).toContain("./nested/visible.ts"); }); + test.skipIf(process.platform !== "linux")( + "preserves non-UTF-8 paths in genuine Git worktree configurations", + async () => { + if (Bun.which("rg") === null || python === null) return; + + const checkout = await repository(); + const configure = [ + "import os, subprocess, sys", + "root = os.fsencode(sys.argv[1])", + "worktree = root + b'/nested-\\xff'", + "gitdir = root + b'/.git/modules/nested-bytes'", + "os.makedirs(os.path.dirname(gitdir), exist_ok=True)", + "subprocess.run([b'git', b'init', b'-q', b'--separate-git-dir', gitdir, worktree], check=True)", + "subprocess.run([b'git', b'--git-dir=' + gitdir, b'config', b'core.worktree', worktree], check=True)", + "with open(worktree + b'/visible.ts', 'wb') as source: source.write(b'tracked\\n')", + "subprocess.run([b'git', b'-C', worktree, b'add', b'visible.ts'], check=True)", + ].join("\n"); + execFileSync(python, ["-B", "-c", configure, checkout], { + stdio: "pipe", + }); + + expect( + (await inventory(checkout)).some((path) => + path.endsWith("/visible.ts"), + ), + ).toBe(true); + }, + ); + test("does not inspect a differently cased checkout outside an explicit scope", async () => { if (Bun.which("rg") === null) return; From 67c3f33082a34e5fbfe16f2154e2234f24722d1d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:41:12 -0700 Subject: [PATCH 071/106] fix(inventory): honor continued Git worktree values --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 6 +++--- sdk/typescript/tests-ts/scan-inventory.test.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) 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 79d3cf43..e5870a85 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -269,9 +269,9 @@ def aliases_canonical_path(path: Path, canonical: str) -> bool: interpolation=None, strict=False, allow_no_value=True ) try: - config.read_string( - os.fsdecode(config_path.read_bytes().removeprefix(codecs.BOM_UTF8)) - ) + contents = config_path.read_bytes().removeprefix(codecs.BOM_UTF8) + contents = re.sub(rb"\\\r?\n", b"", contents) + config.read_string(os.fsdecode(contents)) configured_worktree = config.get("core", "worktree", fallback=None) except (OSError, UnicodeError, configparser.Error) as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6a11afcb..6334f3e5 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1649,7 +1649,8 @@ describe("security scan file inventory", () => { const config = join(gitdir, "config"); const configured = (await readFile(config, "utf8")).replace( /^([ \t]*worktree[ \t]*=[ \t]*)(.+)$/m, - '$1"$2" # valid Git comment', + (_match, prefix: string, value: string) => + `${prefix}"${value.slice(0, -3)}\\\n${value.slice(-3)}" # valid Git comment`, ); const suffix = process.platform === "win32" From 14da726d7b956635c459e37006a1514c1ec1b862 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:43:31 -0700 Subject: [PATCH 072/106] fix(inventory): validate split-index filesystem aliases --- .../scripts/generate_in_scope_files.py | 28 +++++++++++-------- .../tests-ts/scan-inventory.test.ts | 19 ++++++++++--- 2 files changed, 31 insertions(+), 16 deletions(-) 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 e5870a85..9c395b88 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -175,15 +175,15 @@ def inspect_metadata( raise InventoryError("non-regular Git metadata files are not supported") return metadata - def inspect_object_store(objects: Path) -> None: - def aliases_canonical_path(path: Path, canonical: str) -> bool: - try: - actual = path.stat(follow_symlinks=False) - expected = (path.parent / canonical).stat(follow_symlinks=False) - except FileNotFoundError: - return False - return (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino) + def aliases_canonical_path(path: Path, canonical: str) -> bool: + try: + actual = path.stat(follow_symlinks=False) + expected = (path.parent / canonical).stat(follow_symlinks=False) + except FileNotFoundError: + return False + return (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino) + def inspect_object_store(objects: Path) -> None: try: identity = directory_identity(objects) if identity in validated_object_stores: @@ -453,10 +453,14 @@ def aliases_canonical_path(path: Path, canonical: str) -> bool: elif GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): raise InventoryError("Git config includes are not supported") try: - shared_indexes = ( - entry for entry in root.iterdir() if entry.name.startswith("sharedindex.") - ) - for shared_index in shared_indexes: + for shared_index in root.iterdir(): + canonical = shared_index.name.casefold() + if not canonical.startswith("sharedindex."): + continue + if shared_index.name != canonical and not aliases_canonical_path( + shared_index, canonical + ): + continue inspect_metadata(shared_index, directory=False) except FileNotFoundError: continue diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6334f3e5..4e5aaa38 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1411,9 +1411,9 @@ describe("security scan file inventory", () => { }, ); - test.skipIf(process.platform === "win32")( - "rejects split-index backing files that leave the checkout", - async () => { + test.skipIf(process.platform === "win32").each(["lowercase", "uppercase"])( + "rejects %s split-index backing files that leave the checkout", + async (casing) => { if (Bun.which("rg") === null) return; const checkout = await repository(); @@ -1433,7 +1433,18 @@ describe("security scan file inventory", () => { const external = join(dirname(checkout), shared); await writeFile(external, await readFile(original)); await rm(original); - await symlink(external, original); + const replacement = + casing === "uppercase" ? join(gitdir, shared.toUpperCase()) : original; + await symlink(external, replacement); + if ( + casing === "uppercase" && + !(await realpath(original).then( + () => true, + () => false, + )) + ) { + return; + } await expect(inventory(checkout)).rejects.toThrow( "symbolic Git metadata paths are not supported", From 73d0558d0d44e18e205564ed3388774cf91040a0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:52:53 -0700 Subject: [PATCH 073/106] fix(inventory): follow Git-native alternates and pack aliases --- .../scripts/generate_in_scope_files.py | 23 +++++--- .../tests-ts/scan-inventory.test.ts | 53 ++++++++++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) 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 9c395b88..9ddd1834 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -200,21 +200,25 @@ def inspect_object_store(objects: Path) -> None: inspect_metadata(entry, directory=True) if canonical != "info": for member in entry.iterdir(): + member_canonical = member.name.casefold() if canonical == "pack": - if member.name != "multi-pack-index" and not member.name.endswith( + if member_canonical == "multi-pack-index": + expected = member_canonical + elif not member_canonical.endswith( (".pack", ".idx", ".rev", ".bitmap", ".keep", ".promisor", ".mtimes") ): continue + else: + stem, _, suffix = member.name.rpartition(".") + expected = f"{stem}.{suffix.casefold()}" else: - member_canonical = member.name.casefold() if not re.fullmatch( r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member_canonical ): continue - if member.name != member_canonical and not aliases_canonical_path( - member, member_canonical - ): - continue + expected = member_canonical + if member.name != expected and not aliases_canonical_path(member, expected): + continue inspect_metadata(member, directory=False) validated_object_stores.add(identity) except OSError as error: @@ -399,8 +403,13 @@ def inspect_object_store(objects: Path) -> None: if line.startswith(b'"'): if not line.endswith(b'"'): raise InventoryError("invalid Git object alternate paths") + quoted = line[1:-1] + if not re.fullmatch( + rb'(?:[^"\\]|\\(?:["\\abfnrtv]|[0-3][0-7]{2}))*', quoted + ): + raise InventoryError("invalid Git object alternate paths") try: - line = codecs.escape_decode(line[1:-1])[0] + line = codecs.escape_decode(quoted)[0] except (ValueError, UnicodeError) as error: raise InventoryError( "invalid Git object alternate paths" diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4e5aaa38..ff23b02b 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1090,12 +1090,18 @@ describe("security scan file inventory", () => { ["primary-uppercase", "pack"], ["primary-arbitrary-pack", "pack"], ["primary-arbitrary-index", "pack"], + ["primary-midx", "pack"], + ["primary-uppercase-midx", "pack"], + ["primary-uppercase-extension", "pack"], ["primary", "ab"], ["primary-uppercase", "ab"], ["alternate", "pack"], ["alternate-uppercase", "pack"], ["alternate-arbitrary-pack", "pack"], ["alternate-arbitrary-index", "pack"], + ["alternate-midx", "pack"], + ["alternate-uppercase-midx", "pack"], + ["alternate-uppercase-extension", "pack"], ["alternate", "ab"], ["alternate-uppercase", "ab"], ])("rejects symbolic %s Git object files in %s", async (owner, kind) => { @@ -1121,11 +1127,28 @@ describe("security scan file inventory", () => { const basename = owner.includes("arbitrary") ? "arbitrary" : `pack-${hex.repeat(40)}`; - const suffix = owner.endsWith("index") ? "idx" : "pack"; - const member = kind === "pack" ? `${basename}.${suffix}` : hex.repeat(38); + const suffix = owner.endsWith("index") + ? "idx" + : owner.endsWith("extension") + ? "PACK" + : "pack"; + const member = + kind !== "pack" + ? hex.repeat(38) + : owner.endsWith("midx") + ? owner.includes("uppercase") + ? "MULTI-PACK-INDEX" + : "multi-pack-index" + : `${basename}.${suffix}`; await symlink(external, join(directory, member)); - if (kind !== "pack" && hex === "A") { - const aliases = await realpath(join(directory, "a".repeat(38))).then( + if ( + (kind !== "pack" && hex === "A") || + member === "MULTI-PACK-INDEX" || + member.endsWith(".PACK") + ) { + const aliases = await realpath( + join(directory, member.toLowerCase()), + ).then( () => true, () => false, ); @@ -1223,6 +1246,24 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test.each(["\\x61", "\\400"])( + "rejects quoted Git object alternates with unsupported escape %s", + async (escape) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `"${objects}${escape}"\n`, + ); + + await expect(inventory(checkout)).rejects.toThrow( + "invalid Git object alternate paths", + ); + }, + ); + test("rejects differently cased sibling Git object alternates", async () => { if (Bun.which("rg") === null) return; @@ -1249,12 +1290,12 @@ describe("security scan file inventory", () => { if (Bun.which("rg") === null) return; const checkout = await repository(); - const objects = join(checkout, ".git", "extra-objects"); + const objects = join(checkout, ".git", "extra objects"); await mkdir(join(objects, "info"), { recursive: true }); await mkdir(join(objects, "pack")); await writeFile( join(checkout, ".git", "objects", "info", "alternates"), - `${JSON.stringify(objects)}\n`, + `${JSON.stringify(objects).replace("extra objects", "extra\\040objects")}\n`, ); await writeFile(join(checkout, "visible.ts"), "visible\n"); From 8751837e5270921fca31f4389ee96ac5451c1a7e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 06:55:52 -0700 Subject: [PATCH 074/106] fix(inventory): honor effective Git worktree ownership --- .../scripts/generate_in_scope_files.py | 19 ++++++-- .../tests-ts/scan-inventory.test.ts | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) 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 9ddd1834..db29f59b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -273,11 +273,22 @@ def inspect_object_store(objects: Path) -> None: interpolation=None, strict=False, allow_no_value=True ) try: - contents = config_path.read_bytes().removeprefix(codecs.BOM_UTF8) - contents = re.sub(rb"\\\r?\n", b"", contents) - config.read_string(os.fsdecode(contents)) + for candidate in (config_path, gitdir / "config.worktree"): + if candidate != config_path: + extension = config.get( + "extensions", "worktreeconfig", fallback="false" + ) + if extension is not None and not config.getboolean( + "extensions", "worktreeconfig", fallback=False + ): + continue + if inspect_metadata(candidate, directory=False) is None: + continue + contents = candidate.read_bytes().removeprefix(codecs.BOM_UTF8) + contents = re.sub(rb"\\\r?\n", b"", contents) + config.read_string(os.fsdecode(contents)) configured_worktree = config.get("core", "worktree", fallback=None) - except (OSError, UnicodeError, configparser.Error) as error: + except (OSError, UnicodeError, ValueError, configparser.Error) as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if configured_worktree is None: raise InventoryError("Git metadata directory does not own selected worktree") diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ff23b02b..81dcc9c9 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -979,6 +979,50 @@ describe("security scan file inventory", () => { }, ); + test.each(["disabled", "owned", "external"])( + "honors %s worktree-specific Git ownership configuration", + async (ownership) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + const metadata = join(checkout, ".git", "modules", "nested"); + const external = join(dirname(checkout), "external-worktree"); + await mkdir(dirname(metadata), { recursive: true }); + await mkdir(external); + execFileSync( + "git", + ["init", "-q", "--separate-git-dir", metadata, nested], + { + cwd: checkout, + }, + ); + execFileSync("git", ["-C", nested, "config", "core.worktree", nested]); + execFileSync("git", [ + "-C", + nested, + "config", + "extensions.worktreeConfig", + ownership === "disabled" ? "false" : "true", + ]); + await writeFile(join(nested, "visible.ts"), "tracked\n"); + execFileSync("git", ["-C", nested, "add", "visible.ts"]); + const effective = ownership === "owned" ? nested : external; + await writeFile( + join(metadata, "config.worktree"), + `[core]\n\tworktree = ${effective}\n`, + ); + + if (ownership === "external") { + await expect(inventory(checkout)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + } else { + expect(await inventory(checkout)).toContain("./nested/visible.ts"); + } + }, + ); + test("rejects unrelated external Git common directories", async () => { if (Bun.which("rg") === null) return; From bb21d4044ede8eced33b94b82534527570103b1c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:07:36 -0700 Subject: [PATCH 075/106] fix(inventory): unify effective Git worktree configuration --- .../scripts/generate_in_scope_files.py | 130 ++++++++++-------- .../tests-ts/scan-inventory.test.ts | 92 ++++++++++++- 2 files changed, 159 insertions(+), 63 deletions(-) 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 db29f59b..0854f8e5 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -232,9 +232,11 @@ def inspect_object_store(objects: Path) -> None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if symbolic_metadata(metadata): raise InventoryError("symbolic Git metadata paths are not supported") + gitfile = stat.S_ISREG(metadata.st_mode) + backpointer_owned = False if stat.S_ISDIR(metadata.st_mode): gitdir = marker - elif stat.S_ISREG(metadata.st_mode): + elif gitfile: try: contents = marker.read_bytes() except OSError as error: @@ -257,6 +259,7 @@ def inspect_object_store(objects: Path) -> None: internally_owned = directory_identity(ancestor) == directory_identity(repository) backpointer = gitdir / "gitdir" if inspect_metadata(backpointer, directory=False) is not None: + backpointer_owned = True try: target = Path(os.fsdecode(backpointer.read_bytes().rstrip(b"\r\n"))) except (OSError, ValueError) as error: @@ -265,61 +268,7 @@ def inspect_object_store(objects: Path) -> None: target = gitdir / target if not same_filesystem_path(Path(os.path.abspath(target)), marker): raise InventoryError("Git metadata directory does not own selected worktree") - elif internally_owned: - config_path = gitdir / "config" - if inspect_metadata(config_path, directory=False) is None: - raise InventoryError("Git metadata directory does not own selected worktree") - config = configparser.ConfigParser( - interpolation=None, strict=False, allow_no_value=True - ) - try: - for candidate in (config_path, gitdir / "config.worktree"): - if candidate != config_path: - extension = config.get( - "extensions", "worktreeconfig", fallback="false" - ) - if extension is not None and not config.getboolean( - "extensions", "worktreeconfig", fallback=False - ): - continue - if inspect_metadata(candidate, directory=False) is None: - continue - contents = candidate.read_bytes().removeprefix(codecs.BOM_UTF8) - contents = re.sub(rb"\\\r?\n", b"", contents) - config.read_string(os.fsdecode(contents)) - configured_worktree = config.get("core", "worktree", fallback=None) - except (OSError, UnicodeError, ValueError, configparser.Error) as error: - raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if configured_worktree is None: - raise InventoryError("Git metadata directory does not own selected worktree") - quoted = False - escaped = False - for index, character in enumerate(configured_worktree): - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == '"': - quoted = not quoted - elif character in "#;" and not quoted: - configured_worktree = configured_worktree[:index].rstrip() - break - if configured_worktree.startswith('"'): - if not configured_worktree.endswith('"'): - raise InventoryError("invalid Git worktree path") - try: - configured_worktree = os.fsdecode( - codecs.escape_decode(os.fsencode(configured_worktree[1:-1]))[0] - ) - except (ValueError, UnicodeError) as error: - raise InventoryError("invalid Git worktree path") from error - target = Path(configured_worktree) - if not target.is_absolute(): - target = gitdir / target - target = Path(os.path.abspath(target)) - if not same_filesystem_path(target, directory): - raise InventoryError("Git metadata directory does not own selected worktree") - else: + elif not internally_owned: raise InventoryError("Git metadata directory does not own selected worktree") else: return False @@ -350,6 +299,71 @@ def inspect_object_store(objects: Path) -> None: if inspect_metadata(current, directory=True) is None: break roots.append(common) + + def config_value(value: str | None) -> str | None: + if value is None: + return None + quoted = False + escaped = False + for index, character in enumerate(value): + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quoted = not quoted + elif character in "#;" and not quoted: + return value[:index].rstrip() + return value.rstrip() + + config = configparser.ConfigParser(interpolation=None, strict=False, allow_no_value=True) + config_path = roots[-1] / "config" + worktree_config_enabled = False + if inspect_metadata(config_path, directory=False) is not None: + try: + for candidate in (config_path, gitdir / "config.worktree"): + if candidate != config_path: + extension = config_value( + config.get("extensions", "worktreeconfig", fallback="false") + ) + normalized = "true" if extension is None else extension.strip().strip('"').casefold() + worktree_config_enabled = normalized not in ("", "false", "no", "off", "0") + if not worktree_config_enabled: + continue + if inspect_metadata(candidate, directory=False) is None: + continue + contents = candidate.read_bytes().removeprefix(codecs.BOM_UTF8) + contents = re.sub(rb"\\\r?\n", b"", contents) + contents = re.sub( + rb"(?im)^([ \t]*\[[ \t]*)(core|extensions)(?=[ \t]*\])", + lambda section: section.group(1) + section.group(2).lower(), + contents, + ) + config.read_string(os.fsdecode(contents)) + except (OSError, UnicodeError, ValueError, configparser.Error) as error: + raise InventoryError(f"could not inspect Git metadata: {directory}") from error + + configured_worktree = config_value(config.get("core", "worktree", fallback=None)) + if gitfile: + if configured_worktree is None: + if not backpointer_owned: + raise InventoryError("Git metadata directory does not own selected worktree") + else: + if configured_worktree.startswith('"'): + if not configured_worktree.endswith('"'): + raise InventoryError("invalid Git worktree path") + try: + configured_worktree = os.fsdecode( + codecs.escape_decode(os.fsencode(configured_worktree[1:-1]))[0] + ) + except (ValueError, UnicodeError) as error: + raise InventoryError("invalid Git worktree path") from error + target = Path(configured_worktree) + if not target.is_absolute(): + target = gitdir / target + if not same_filesystem_path(Path(os.path.abspath(target)), directory): + raise InventoryError("Git metadata directory does not own selected worktree") + for root in roots: for relative in ( "HEAD", @@ -368,6 +382,10 @@ def inspect_object_store(objects: Path) -> None: "objects/info", "objects/info/alternates", ): + if relative == "config.worktree" and ( + not worktree_config_enabled or root != gitdir + ): + continue path = root / relative metadata = inspect_metadata( path, diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 81dcc9c9..34429bcc 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -979,10 +979,20 @@ describe("security scan file inventory", () => { }, ); - test.each(["disabled", "owned", "external"])( + test.each([ + "disabled", + "disabled-comment", + "disabled-empty", + "disabled-symlink", + "owned", + "external", + "mixed-case-external", + ])( "honors %s worktree-specific Git ownership configuration", async (ownership) => { if (Bun.which("rg") === null) return; + if (ownership === "disabled-symlink" && process.platform === "win32") + return; const checkout = await repository(); const nested = join(checkout, "nested"); @@ -1003,17 +1013,42 @@ describe("security scan file inventory", () => { nested, "config", "extensions.worktreeConfig", - ownership === "disabled" ? "false" : "true", + ownership.startsWith("disabled") ? "false" : "true", ]); await writeFile(join(nested, "visible.ts"), "tracked\n"); execFileSync("git", ["-C", nested, "add", "visible.ts"]); const effective = ownership === "owned" ? nested : external; - await writeFile( - join(metadata, "config.worktree"), - `[core]\n\tworktree = ${effective}\n`, - ); + const config = join(metadata, "config"); + if (ownership === "mixed-case-external") { + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^\[extensions\]$/im, + "[Extensions]", + ), + ); + } else if ( + ownership === "disabled-comment" || + ownership === "disabled-empty" + ) { + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktreeConfig[ \t]*=[ \t]*)false$/im, + ownership === "disabled-comment" ? "$1false # disabled" : "$1", + ), + ); + } + const override = `[${ownership === "mixed-case-external" ? "Core" : "core"}]\n\tworktree = ${effective}\n`; + if (ownership === "disabled-symlink") { + const unused = join(dirname(checkout), "unused.config"); + await writeFile(unused, override); + await symlink(unused, join(metadata, "config.worktree")); + } else { + await writeFile(join(metadata, "config.worktree"), override); + } - if (ownership === "external") { + if (ownership === "external" || ownership === "mixed-case-external") { await expect(inventory(checkout)).rejects.toThrow( "Git metadata directory does not own selected worktree", ); @@ -1687,6 +1722,49 @@ describe("security scan file inventory", () => { expect(await inventory(linked)).toContain("./visible.ts"); }); + test.each(["owned", "external"])( + "honors %s worktree-specific ownership for linked Git worktrees", + async (ownership) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await writeFile(join(checkout, "visible.ts"), "tracked\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: checkout }); + commit(checkout); + const linked = join(dirname(checkout), "linked-worktree"); + const external = join(dirname(checkout), "external-worktree"); + await mkdir(external); + execFileSync("git", ["worktree", "add", "--detach", linked, "HEAD"], { + cwd: checkout, + stdio: "ignore", + }); + execFileSync("git", ["config", "extensions.worktreeConfig", "true"], { + cwd: checkout, + }); + const metadata = execFileSync( + "git", + ["rev-parse", "--absolute-git-dir"], + { + cwd: linked, + encoding: "utf8", + }, + ).trim(); + const effective = ownership === "owned" ? linked : external; + await writeFile( + join(metadata, "config.worktree"), + `[Core]\n\tworktree = ${effective}\n`, + ); + + if (ownership === "external") { + await expect(inventory(linked)).rejects.toThrow( + "Git metadata directory does not own selected worktree", + ); + } else { + expect(await inventory(linked)).toContain("./visible.ts"); + } + }, + ); + test("rejects worktree backpointers hard-linked through equivalent sibling names", async () => { if (Bun.which("rg") === null) return; From 3b4e946e8655676b69abe1177a3b9fae77ee5922 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:12:08 -0700 Subject: [PATCH 076/106] fix(inventory): preserve Git config comment boundaries --- .../scripts/generate_in_scope_files.py | 32 ++++++++++++++++++- .../tests-ts/scan-inventory.test.ts | 22 +++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) 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 0854f8e5..99243b9b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -316,6 +316,36 @@ def config_value(value: str | None) -> str | None: return value[:index].rstrip() return value.rstrip() + def join_config_lines(contents: bytes) -> bytes: + joined = bytearray() + quoted = False + comment = False + escaped = False + position = 0 + while position < len(contents): + character = contents[position] + if character == ord("\n"): + quoted = False + comment = False + escaped = False + elif not comment and not escaped and character == ord("\\"): + if contents[position + 1 : position + 2] == b"\n": + position += 2 + continue + if contents[position + 1 : position + 3] == b"\r\n": + position += 3 + continue + escaped = True + elif escaped: + escaped = False + elif not comment and character == ord('"'): + quoted = not quoted + elif not quoted and character in (ord("#"), ord(";")): + comment = True + joined.append(character) + position += 1 + return bytes(joined) + config = configparser.ConfigParser(interpolation=None, strict=False, allow_no_value=True) config_path = roots[-1] / "config" worktree_config_enabled = False @@ -333,7 +363,7 @@ def config_value(value: str | None) -> str | None: if inspect_metadata(candidate, directory=False) is None: continue contents = candidate.read_bytes().removeprefix(codecs.BOM_UTF8) - contents = re.sub(rb"\\\r?\n", b"", contents) + contents = join_config_lines(contents) contents = re.sub( rb"(?im)^([ \t]*\[[ \t]*)(core|extensions)(?=[ \t]*\])", lambda section: section.group(1) + section.group(2).lower(), diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 34429bcc..16bc0448 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -984,6 +984,8 @@ describe("security scan file inventory", () => { "disabled-comment", "disabled-empty", "disabled-symlink", + "hash-comment-override", + "semicolon-comment-override", "owned", "external", "mixed-case-external", @@ -1013,7 +1015,10 @@ describe("security scan file inventory", () => { nested, "config", "extensions.worktreeConfig", - ownership.startsWith("disabled") ? "false" : "true", + ownership.startsWith("disabled") || + ownership.endsWith("comment-override") + ? "false" + : "true", ]); await writeFile(join(nested, "visible.ts"), "tracked\n"); execFileSync("git", ["-C", nested, "add", "visible.ts"]); @@ -1038,6 +1043,15 @@ describe("security scan file inventory", () => { ownership === "disabled-comment" ? "$1false # disabled" : "$1", ), ); + } else if (ownership.endsWith("comment-override")) { + const comment = ownership.startsWith("hash") ? "#" : ";"; + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=.*)$/im, + `$1\n\t${comment} owner comment \\\n\tworktree = ${external}`, + ), + ); } const override = `[${ownership === "mixed-case-external" ? "Core" : "core"}]\n\tworktree = ${effective}\n`; if (ownership === "disabled-symlink") { @@ -1048,7 +1062,11 @@ describe("security scan file inventory", () => { await writeFile(join(metadata, "config.worktree"), override); } - if (ownership === "external" || ownership === "mixed-case-external") { + if ( + ownership === "external" || + ownership === "mixed-case-external" || + ownership.endsWith("comment-override") + ) { await expect(inventory(checkout)).rejects.toThrow( "Git metadata directory does not own selected worktree", ); From d25aacfcb049522aa6534e88a05735f8acb45fd5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:25:05 -0700 Subject: [PATCH 077/106] fix(inventory): match native Git ownership and path semantics --- .../scripts/generate_in_scope_files.py | 34 +++++++---- .../tests-ts/scan-inventory.test.ts | 58 +++++++++++++++++-- 2 files changed, 77 insertions(+), 15 deletions(-) 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 99243b9b..77fd7055 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -346,7 +346,9 @@ def join_config_lines(contents: bytes) -> bytes: position += 1 return bytes(joined) - config = configparser.ConfigParser(interpolation=None, strict=False, allow_no_value=True) + config = configparser.ConfigParser( + interpolation=None, strict=False, allow_no_value=True, default_section="\0" + ) config_path = roots[-1] / "config" worktree_config_enabled = False if inspect_metadata(config_path, directory=False) is not None: @@ -379,15 +381,25 @@ def join_config_lines(contents: bytes) -> bytes: if not backpointer_owned: raise InventoryError("Git metadata directory does not own selected worktree") else: - if configured_worktree.startswith('"'): - if not configured_worktree.endswith('"'): - raise InventoryError("invalid Git worktree path") - try: - configured_worktree = os.fsdecode( - codecs.escape_decode(os.fsencode(configured_worktree[1:-1]))[0] - ) - except (ValueError, UnicodeError) as error: - raise InventoryError("invalid Git worktree path") from error + decoded = bytearray() + quoted = False + escaped = False + for character in os.fsencode(configured_worktree): + if escaped: + replacements = {ord("n"): ord("\n"), ord("t"): ord("\t"), ord("b"): ord("\b")} + if character not in (*replacements, ord('"'), ord("\\")): + raise InventoryError("invalid Git worktree path") + decoded.append(replacements.get(character, character)) + escaped = False + elif character == ord("\\"): + escaped = True + elif character == ord('"'): + quoted = not quoted + else: + decoded.append(character) + if quoted or escaped: + raise InventoryError("invalid Git worktree path") + configured_worktree = os.fsdecode(bytes(decoded)) target = Path(configured_worktree) if not target.is_absolute(): target = gitdir / target @@ -481,7 +493,7 @@ def join_config_lines(contents: bytes) -> bytes: ( candidate for candidate in (repository, *roots) - if alternate.parts[: len(candidate.parts)] == candidate.parts + if alternate.is_relative_to(candidate) ), None, ) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 16bc0448..8d3c68b3 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -986,6 +986,8 @@ describe("security scan file inventory", () => { "disabled-symlink", "hash-comment-override", "semicolon-comment-override", + "default-inheritance", + "unquoted-escape", "owned", "external", "mixed-case-external", @@ -995,11 +997,19 @@ describe("security scan file inventory", () => { if (Bun.which("rg") === null) return; if (ownership === "disabled-symlink" && process.platform === "win32") return; + if (ownership === "unquoted-escape" && process.platform === "win32") + return; const checkout = await repository(); - const nested = join(checkout, "nested"); + const nested = join( + checkout, + ownership === "unquoted-escape" ? "nested\\towner" : "nested", + ); const metadata = join(checkout, ".git", "modules", "nested"); - const external = join(dirname(checkout), "external-worktree"); + const external = + ownership === "unquoted-escape" + ? join(checkout, "nested\towner") + : join(dirname(checkout), "external-worktree"); await mkdir(dirname(metadata), { recursive: true }); await mkdir(external); execFileSync( @@ -1016,7 +1026,9 @@ describe("security scan file inventory", () => { "config", "extensions.worktreeConfig", ownership.startsWith("disabled") || - ownership.endsWith("comment-override") + ownership.endsWith("comment-override") || + ownership === "default-inheritance" || + ownership === "unquoted-escape" ? "false" : "true", ]); @@ -1052,6 +1064,23 @@ describe("security scan file inventory", () => { `$1\n\t${comment} owner comment \\\n\tworktree = ${external}`, ), ); + } else if (ownership === "default-inheritance") { + const withoutOwner = (await readFile(config, "utf8")).replace( + /^[ \t]*worktree[ \t]*=.*\n/im, + "", + ); + await writeFile( + config, + `${withoutOwner}\n[DEFAULT]\n\tworktree = ${nested}\n`, + ); + } else if (ownership === "unquoted-escape") { + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=).*$/im, + `$1 ${nested}`, + ), + ); } const override = `[${ownership === "mixed-case-external" ? "Core" : "core"}]\n\tworktree = ${effective}\n`; if (ownership === "disabled-symlink") { @@ -1065,7 +1094,9 @@ describe("security scan file inventory", () => { if ( ownership === "external" || ownership === "mixed-case-external" || - ownership.endsWith("comment-override") + ownership.endsWith("comment-override") || + ownership === "default-inheritance" || + ownership === "unquoted-escape" ) { await expect(inventory(checkout)).rejects.toThrow( "Git metadata directory does not own selected worktree", @@ -1399,6 +1430,25 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); + test.skipIf(process.platform !== "win32")( + "allows case-equivalent contained Git object alternate paths", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${objects.toUpperCase()}\n`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); + test("allows repository-owned transitive Git object alternates", async () => { if (Bun.which("rg") === null) return; From 0ad010017742b19dd56c21353e3b944d34c90ce3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:33:26 -0700 Subject: [PATCH 078/106] fix(inventory): preserve valid Git metadata and Windows aliases --- .../scripts/generate_in_scope_files.py | 9 +++++---- sdk/typescript/tests-ts/scan-inventory.test.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) 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 77fd7055..3578e648 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -250,11 +250,10 @@ def inspect_object_store(objects: Path) -> None: for current in reversed((gitdir, *gitdir.parents)): if inspect_metadata(current, directory=True) is None: break - repository_parts = repository.parts - internally_owned = gitdir.parts[: len(repository_parts)] == repository_parts + internally_owned = gitdir.is_relative_to(repository) if internally_owned: ancestor = gitdir - for _ in range(len(gitdir.parts) - len(repository_parts)): + for _ in range(len(gitdir.parts) - len(repository.parts)): ancestor = ancestor.parent internally_owned = directory_identity(ancestor) == directory_identity(repository) backpointer = gitdir / "gitdir" @@ -535,7 +534,9 @@ def join_config_lines(contents: bytes) -> bytes: try: for shared_index in root.iterdir(): canonical = shared_index.name.casefold() - if not canonical.startswith("sharedindex."): + if not re.fullmatch( + r"sharedindex\.(?:[0-9a-f]{40}|[0-9a-f]{64})", canonical + ): continue if shared_index.name != canonical and not aliases_canonical_path( shared_index, canonical diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 8d3c68b3..1ed92fdd 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -988,6 +988,7 @@ describe("security scan file inventory", () => { "semicolon-comment-override", "default-inheritance", "unquoted-escape", + "case-alias", "owned", "external", "mixed-case-external", @@ -999,6 +1000,7 @@ describe("security scan file inventory", () => { return; if (ownership === "unquoted-escape" && process.platform === "win32") return; + if (ownership === "case-alias" && process.platform !== "win32") return; const checkout = await repository(); const nested = join( @@ -1034,7 +1036,8 @@ describe("security scan file inventory", () => { ]); await writeFile(join(nested, "visible.ts"), "tracked\n"); execFileSync("git", ["-C", nested, "add", "visible.ts"]); - const effective = ownership === "owned" ? nested : external; + const effective = + ownership === "owned" || ownership === "case-alias" ? nested : external; const config = join(metadata, "config"); if (ownership === "mixed-case-external") { await writeFile( @@ -1090,6 +1093,12 @@ describe("security scan file inventory", () => { } else { await writeFile(join(metadata, "config.worktree"), override); } + if (ownership === "case-alias") { + await writeFile( + join(nested, ".git"), + `gitdir: ${metadata.toUpperCase()}\n`, + ); + } if ( ownership === "external" || @@ -1615,6 +1624,7 @@ describe("security scan file inventory", () => { name.startsWith("sharedindex."), ); if (shared === undefined) throw new Error("Expected a split Git index."); + await mkdir(join(gitdir, "sharedindex.notes")); expect(await inventory(checkout)).toContain("./tracked.ts"); const original = join(gitdir, shared); From 4fe37acecc1940141ca527ce9beed8346c83b80a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:38:02 -0700 Subject: [PATCH 079/106] fix(inventory): parse effective ownership using Git semantics --- .../scripts/generate_in_scope_files.py | 55 ++++++++++++++----- .../tests-ts/scan-inventory.test.ts | 11 ++++ 2 files changed, 51 insertions(+), 15 deletions(-) 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 3578e648..2df74e96 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -5,7 +5,6 @@ import argparse import codecs -import configparser import io import os import re @@ -345,18 +344,14 @@ def join_config_lines(contents: bytes) -> bytes: position += 1 return bytes(joined) - config = configparser.ConfigParser( - interpolation=None, strict=False, allow_no_value=True, default_section="\0" - ) + options: dict[tuple[str, str], str | None] = {} config_path = roots[-1] / "config" worktree_config_enabled = False if inspect_metadata(config_path, directory=False) is not None: try: for candidate in (config_path, gitdir / "config.worktree"): if candidate != config_path: - extension = config_value( - config.get("extensions", "worktreeconfig", fallback="false") - ) + extension = options.get(("extensions", "worktreeconfig"), "false") normalized = "true" if extension is None else extension.strip().strip('"').casefold() worktree_config_enabled = normalized not in ("", "false", "no", "off", "0") if not worktree_config_enabled: @@ -365,16 +360,46 @@ def join_config_lines(contents: bytes) -> bytes: continue contents = candidate.read_bytes().removeprefix(codecs.BOM_UTF8) contents = join_config_lines(contents) - contents = re.sub( - rb"(?im)^([ \t]*\[[ \t]*)(core|extensions)(?=[ \t]*\])", - lambda section: section.group(1) + section.group(2).lower(), - contents, - ) - config.read_string(os.fsdecode(contents)) - except (OSError, UnicodeError, ValueError, configparser.Error) as error: + section = None + for raw in os.fsdecode(contents).split("\n"): + line = raw.lstrip(" \t").rstrip("\r") + if not line or line.startswith(("#", ";")): + continue + if line.startswith("["): + match = re.match( + r"\[[ \t]*(core|extensions)[ \t]*\](?=[ \t]*(?:[#;]|$))", + line, + re.IGNORECASE, + ) + section = None if match is None else match.group(1).casefold() + continue + if section is None: + continue + assignment = re.match( + r"([a-z][a-z0-9-]*)(?:([ \t]*=)[ \t]*(.*))?", + line, + re.IGNORECASE, + ) + if assignment is None: + continue + if assignment.group(2) is None: + remainder = line[assignment.end() :].lstrip(" \t") + if remainder and not remainder.startswith(("#", ";")): + continue + key = assignment.group(1).casefold() + if (section, key) in ( + ("core", "worktree"), + ("extensions", "worktreeconfig"), + ): + options[(section, key)] = ( + None + if assignment.group(2) is None + else config_value(assignment.group(3)) + ) + except (OSError, UnicodeError, ValueError) as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error - configured_worktree = config_value(config.get("core", "worktree", fallback=None)) + configured_worktree = options.get(("core", "worktree")) if gitfile: if configured_worktree is None: if not backpointer_owned: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 1ed92fdd..c3639575 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -986,6 +986,7 @@ describe("security scan file inventory", () => { "disabled-symlink", "hash-comment-override", "semicolon-comment-override", + "indented-override", "default-inheritance", "unquoted-escape", "case-alias", @@ -1029,6 +1030,7 @@ describe("security scan file inventory", () => { "extensions.worktreeConfig", ownership.startsWith("disabled") || ownership.endsWith("comment-override") || + ownership === "indented-override" || ownership === "default-inheritance" || ownership === "unquoted-escape" ? "false" @@ -1067,6 +1069,14 @@ describe("security scan file inventory", () => { `$1\n\t${comment} owner comment \\\n\tworktree = ${external}`, ), ); + } else if (ownership === "indented-override") { + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=.*)$/im, + `$1 # selected owner\n\t\tworktree = ${external}`, + ), + ); } else if (ownership === "default-inheritance") { const withoutOwner = (await readFile(config, "utf8")).replace( /^[ \t]*worktree[ \t]*=.*\n/im, @@ -1104,6 +1114,7 @@ describe("security scan file inventory", () => { ownership === "external" || ownership === "mixed-case-external" || ownership.endsWith("comment-override") || + ownership === "indented-override" || ownership === "default-inheritance" || ownership === "unquoted-escape" ) { From ce3b3f672772ff9a797931c49819c31f599a5737 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:42:01 -0700 Subject: [PATCH 080/106] fix(inventory): validate contained filesystem aliases by identity --- .../scripts/generate_in_scope_files.py | 15 +++++-- .../tests-ts/scan-inventory.test.ts | 45 ++++++++++--------- 2 files changed, 36 insertions(+), 24 deletions(-) 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 2df74e96..cdd897ea 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -249,12 +249,12 @@ def inspect_object_store(objects: Path) -> None: for current in reversed((gitdir, *gitdir.parents)): if inspect_metadata(current, directory=True) is None: break - internally_owned = gitdir.is_relative_to(repository) + internally_owned = len(gitdir.parts) >= len(repository.parts) if internally_owned: ancestor = gitdir for _ in range(len(gitdir.parts) - len(repository.parts)): ancestor = ancestor.parent - internally_owned = directory_identity(ancestor) == directory_identity(repository) + internally_owned = same_filesystem_path(ancestor, repository) backpointer = gitdir / "gitdir" if inspect_metadata(backpointer, directory=False) is not None: backpointer_owned = True @@ -517,7 +517,14 @@ def join_config_lines(contents: bytes) -> bytes: ( candidate for candidate in (repository, *roots) - if alternate.is_relative_to(candidate) + if len(alternate.parts) >= len(candidate.parts) + and all( + unicodedata.normalize("NFC", actual).casefold() + == unicodedata.normalize("NFC", expected).casefold() + for actual, expected in zip( + alternate.parts, candidate.parts + ) + ) ), None, ) @@ -530,7 +537,7 @@ def join_config_lines(contents: bytes) -> bytes: "external Git object alternates are not supported" ) current = owner - for component in alternate.relative_to(owner).parts: + for component in alternate.parts[len(owner.parts) :]: current /= component if inspect_metadata(current, directory=True) is None: raise InventoryError( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index c3639575..d4a56b85 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1001,7 +1001,6 @@ describe("security scan file inventory", () => { return; if (ownership === "unquoted-escape" && process.platform === "win32") return; - if (ownership === "case-alias" && process.platform !== "win32") return; const checkout = await repository(); const nested = join( @@ -1104,10 +1103,13 @@ describe("security scan file inventory", () => { await writeFile(join(metadata, "config.worktree"), override); } if (ownership === "case-alias") { - await writeFile( - join(nested, ".git"), - `gitdir: ${metadata.toUpperCase()}\n`, + const alias = metadata.toUpperCase(); + const equivalent = await realpath(alias).then( + async (resolved) => resolved === (await realpath(metadata)), + () => false, ); + if (!equivalent) return; + await writeFile(join(nested, ".git"), `gitdir: ${alias}\n`); } if ( @@ -1450,24 +1452,27 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); - test.skipIf(process.platform !== "win32")( - "allows case-equivalent contained Git object alternate paths", - async () => { - if (Bun.which("rg") === null) return; + test("allows case-equivalent contained Git object alternate paths", async () => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const objects = join(checkout, ".git", "extra-objects"); - await mkdir(join(objects, "info"), { recursive: true }); - await mkdir(join(objects, "pack")); - await writeFile( - join(checkout, ".git", "objects", "info", "alternates"), - `${objects.toUpperCase()}\n`, - ); - await writeFile(join(checkout, "visible.ts"), "visible\n"); + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + const alias = objects.toUpperCase(); + const equivalent = await realpath(alias).then( + async (resolved) => resolved === (await realpath(objects)), + () => false, + ); + if (!equivalent) return; + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${alias}\n`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); - expect(await inventory(checkout)).toContain("./visible.ts"); - }, - ); + expect(await inventory(checkout)).toContain("./visible.ts"); + }); test("allows repository-owned transitive Git object alternates", async () => { if (Bun.which("rg") === null) return; From b5978ab78e089e717dd8262ab8af7970cd438e67 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 07:56:36 -0700 Subject: [PATCH 081/106] fix(inventory): preserve native Git ownership and active metadata --- .../scripts/generate_in_scope_files.py | 28 ++++++- .../tests-ts/scan-inventory.test.ts | 74 ++++++++++++++++--- 2 files changed, 90 insertions(+), 12 deletions(-) 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 cdd897ea..724ef22a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -254,7 +254,7 @@ def inspect_object_store(objects: Path) -> None: ancestor = gitdir for _ in range(len(gitdir.parts) - len(repository.parts)): ancestor = ancestor.parent - internally_owned = same_filesystem_path(ancestor, repository) + internally_owned = directory_identity(ancestor) == directory_identity(repository) backpointer = gitdir / "gitdir" if inspect_metadata(backpointer, directory=False) is not None: backpointer_owned = True @@ -406,6 +406,7 @@ def join_config_lines(contents: bytes) -> bytes: raise InventoryError("Git metadata directory does not own selected worktree") else: decoded = bytearray() + normalized = bytearray() quoted = False escaped = False for character in os.fsencode(configured_worktree): @@ -413,7 +414,9 @@ def join_config_lines(contents: bytes) -> bytes: replacements = {ord("n"): ord("\n"), ord("t"): ord("\t"), ord("b"): ord("\b")} if character not in (*replacements, ord('"'), ord("\\")): raise InventoryError("invalid Git worktree path") - decoded.append(replacements.get(character, character)) + replacement = replacements.get(character, character) + decoded.append(replacement) + normalized.append(replacement) escaped = False elif character == ord("\\"): escaped = True @@ -421,12 +424,31 @@ def join_config_lines(contents: bytes) -> bytes: quoted = not quoted else: decoded.append(character) + normalized.append( + ord(" ") if character == ord("\t") and not quoted else character + ) if quoted or escaped: raise InventoryError("invalid Git worktree path") configured_worktree = os.fsdecode(bytes(decoded)) target = Path(configured_worktree) if not target.is_absolute(): target = gitdir / target + if decoded != normalized: + normalized_target = Path(os.fsdecode(bytes(normalized))) + if not normalized_target.is_absolute(): + normalized_target = gitdir / normalized_target + try: + normalized_metadata = normalized_target.stat(follow_symlinks=False) + except FileNotFoundError: + pass + else: + if symbolic_metadata(normalized_metadata) or ( + normalized_metadata.st_dev, + normalized_metadata.st_ino, + ) != directory_identity(directory): + raise InventoryError( + "Git metadata directory does not own selected worktree" + ) if not same_filesystem_path(Path(os.path.abspath(target)), directory): raise InventoryError("Git metadata directory does not own selected worktree") @@ -452,6 +474,8 @@ def join_config_lines(contents: bytes) -> bytes: not worktree_config_enabled or root != gitdir ): continue + if root != roots[-1] and (relative == "objects" or relative.startswith("objects/")): + continue path = root / relative metadata = inspect_metadata( path, diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d4a56b85..d334f3f9 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -14,7 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { normalizeTarget } from "../src/targets.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -989,7 +989,10 @@ describe("security scan file inventory", () => { "indented-override", "default-inheritance", "unquoted-escape", + "literal-tab", + "literal-tab-owned", "case-alias", + "short-alias", "owned", "external", "mixed-case-external", @@ -1001,17 +1004,30 @@ describe("security scan file inventory", () => { return; if (ownership === "unquoted-escape" && process.platform === "win32") return; + if (ownership.startsWith("literal-tab") && process.platform === "win32") + return; + if ( + ownership === "short-alias" && + (process.platform !== "win32" || python === null) + ) + return; const checkout = await repository(); const nested = join( checkout, - ownership === "unquoted-escape" ? "nested\\towner" : "nested", + ownership === "unquoted-escape" + ? "nested\\towner" + : ownership.startsWith("literal-tab") + ? "nested\towner" + : "nested", ); const metadata = join(checkout, ".git", "modules", "nested"); const external = ownership === "unquoted-escape" ? join(checkout, "nested\towner") - : join(dirname(checkout), "external-worktree"); + : ownership === "literal-tab" + ? join(checkout, "nested owner") + : join(dirname(checkout), "external-worktree"); await mkdir(dirname(metadata), { recursive: true }); await mkdir(external); execFileSync( @@ -1031,14 +1047,19 @@ describe("security scan file inventory", () => { ownership.endsWith("comment-override") || ownership === "indented-override" || ownership === "default-inheritance" || - ownership === "unquoted-escape" + ownership === "unquoted-escape" || + ownership.startsWith("literal-tab") ? "false" : "true", ]); await writeFile(join(nested, "visible.ts"), "tracked\n"); execFileSync("git", ["-C", nested, "add", "visible.ts"]); const effective = - ownership === "owned" || ownership === "case-alias" ? nested : external; + ownership === "owned" || + ownership === "case-alias" || + ownership === "short-alias" + ? nested + : external; const config = join(metadata, "config"); if (ownership === "mixed-case-external") { await writeFile( @@ -1085,7 +1106,10 @@ describe("security scan file inventory", () => { config, `${withoutOwner}\n[DEFAULT]\n\tworktree = ${nested}\n`, ); - } else if (ownership === "unquoted-escape") { + } else if ( + ownership === "unquoted-escape" || + ownership.startsWith("literal-tab") + ) { await writeFile( config, (await readFile(config, "utf8")).replace( @@ -1102,8 +1126,32 @@ describe("security scan file inventory", () => { } else { await writeFile(join(metadata, "config.worktree"), override); } - if (ownership === "case-alias") { - const alias = metadata.toUpperCase(); + if (ownership === "case-alias" || ownership === "short-alias") { + const alias = + ownership === "case-alias" + ? metadata.toUpperCase() + : execFileSync( + python!, + [ + "-B", + "-c", + [ + "import ctypes, sys", + "function = ctypes.windll.kernel32.GetShortPathNameW", + "size = function(sys.argv[1], None, 0)", + "buffer = ctypes.create_unicode_buffer(size) if size else None", + "print(buffer.value if buffer is not None and function(sys.argv[1], buffer, size) else '')", + ].join("\n"), + metadata, + ], + { encoding: "utf8" }, + ).trim(); + if ( + !alias || + (ownership === "short-alias" && + alias.toLowerCase() === metadata.toLowerCase()) + ) + return; const equivalent = await realpath(alias).then( async (resolved) => resolved === (await realpath(metadata)), () => false, @@ -1118,13 +1166,16 @@ describe("security scan file inventory", () => { ownership.endsWith("comment-override") || ownership === "indented-override" || ownership === "default-inheritance" || - ownership === "unquoted-escape" + ownership === "unquoted-escape" || + ownership === "literal-tab" ) { await expect(inventory(checkout)).rejects.toThrow( "Git metadata directory does not own selected worktree", ); } else { - expect(await inventory(checkout)).toContain("./nested/visible.ts"); + expect(await inventory(checkout)).toContain( + `./${basename(nested)}/visible.ts`, + ); } }, ); @@ -1791,6 +1842,9 @@ describe("security scan file inventory", () => { const gitdir = (await readFile(join(linked, ".git"), "utf8")) .replace(/^gitdir: /, "") .trim(); + await writeFile(join(gitdir, "objects"), "inactive worktree metadata\n"); + expect(await inventory(linked)).toContain("./visible.ts"); + const alternateBackpointer = join( dirname(linked), "LINKED-WORKTREE", From 0694dba220b0c84c029c56804026a195524bac93 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 08:11:29 -0700 Subject: [PATCH 082/106] fix(inventory): honor effective Git roots and filesystem aliases --- .../scripts/generate_in_scope_files.py | 41 ++++-- .../tests-ts/scan-inventory.test.ts | 119 +++++++++++++----- 2 files changed, 118 insertions(+), 42 deletions(-) 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 724ef22a..ae9ab50e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -65,10 +65,22 @@ def resolve_scope(repository: Path, value: str) -> str: except (OSError, ValueError) as error: raise InventoryError(f"--scope: path does not exist: {value}") from error + repository_metadata = repository.stat(follow_symlinks=False) + repository_identity = (repository_metadata.st_dev, repository_metadata.st_ino) try: relative = resolved.relative_to(repository) except ValueError as error: - raise InventoryError(f"--scope: path must remain inside --repo: {value}") from error + ancestor = resolved + while True: + metadata = ancestor.stat(follow_symlinks=False) + if (metadata.st_dev, metadata.st_ino) == repository_identity: + relative = Path(*resolved.parts[len(ancestor.parts) :]) + break + if ancestor == ancestor.parent: + raise InventoryError( + f"--scope: path must remain inside --repo: {value}" + ) from error + ancestor = ancestor.parent parent = repository for component in relative.parts: if git_metadata_path(parent, component): @@ -76,12 +88,14 @@ def resolve_scope(repository: Path, value: str) -> str: parent /= component current = scope - while current != repository: - if current == current.parent: - raise InventoryError("--scope: symbolic links are not supported") + while True: metadata = current.stat(follow_symlinks=False) if symbolic_metadata(metadata): raise InventoryError("--scope: symbolic links are not supported") + if (metadata.st_dev, metadata.st_ino) == repository_identity: + break + if current == current.parent: + raise InventoryError("--scope: symbolic links are not supported") current = current.parent if not resolved.is_dir() and not resolved.is_file(): @@ -440,7 +454,9 @@ def join_config_lines(contents: bytes) -> bytes: try: normalized_metadata = normalized_target.stat(follow_symlinks=False) except FileNotFoundError: - pass + raise InventoryError( + "Git metadata directory does not own selected worktree" + ) from None else: if symbolic_metadata(normalized_metadata) or ( normalized_metadata.st_dev, @@ -470,12 +486,17 @@ def join_config_lines(contents: bytes) -> bytes: "objects/info", "objects/info/alternates", ): - if relative == "config.worktree" and ( - not worktree_config_enabled or root != gitdir + effective_root = ( + gitdir + if relative in ("HEAD", "index", "config.worktree", "info/sparse-checkout") + else roots[-1] + ) + if root != effective_root or ( + relative == "config.worktree" and not worktree_config_enabled ): continue - if root != roots[-1] and (relative == "objects" or relative.startswith("objects/")): - continue + if relative == "info/sparse-checkout" and root != roots[-1]: + inspect_metadata(root / "info", directory=True) path = root / relative metadata = inspect_metadata( path, @@ -587,6 +608,8 @@ def join_config_lines(contents: bytes) -> bytes: pending.append((alternate, records)) elif GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): raise InventoryError("Git config includes are not supported") + if root != gitdir: + continue try: for shared_index in root.iterdir(): canonical = shared_index.name.casefold() diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d334f3f9..58db24f0 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -57,6 +57,27 @@ function commit(checkout: string): void { ); } +function windowsShortPath(path: string): string | null { + if (process.platform !== "win32" || python === null) return null; + const alias = execFileSync( + python, + [ + "-B", + "-c", + [ + "import ctypes, sys", + "function = ctypes.windll.kernel32.GetShortPathNameW", + "size = function(sys.argv[1], None, 0)", + "buffer = ctypes.create_unicode_buffer(size) if size else None", + "print(buffer.value if buffer is not None and function(sys.argv[1], buffer, size) else '')", + ].join("\n"), + path, + ], + { encoding: "utf8" }, + ).trim(); + return alias && alias.toLowerCase() !== path.toLowerCase() ? alias : null; +} + async function inventory( checkout: string, scope = ".", @@ -217,6 +238,50 @@ describe("security scan file inventory", () => { }, ); + test.each(["case-alias", "short-alias"])( + "accepts absolute directory scopes through a %s", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + await writeFile(join(nested, "visible.ts"), "visible\n"); + const alias = + kind === "case-alias" + ? join(dirname(checkout), basename(checkout).toUpperCase()) + : windowsShortPath(checkout); + if (alias === null) return; + const equivalent = await realpath(alias).then( + async (resolved) => resolved === (await realpath(checkout)), + () => false, + ); + if (!equivalent) return; + + expect(await inventory(checkout, join(alias, "nested"))).toContain( + "nested/visible.ts", + ); + }, + ); + + test.skipIf(process.platform === "win32")( + "rejects symbolic absolute directory scopes", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + const alias = join(checkout, "alias"); + await mkdir(nested); + await writeFile(join(nested, "visible.ts"), "visible\n"); + await symlink(nested, alias); + + await expect(inventory(checkout, alias)).rejects.toThrow( + "symbolic links are not supported", + ); + }, + ); + test.each([".ignore", ".rgignore"])( "keeps nested checkout files re-included by higher-precedence %s rules", async (override) => { @@ -990,7 +1055,8 @@ describe("security scan file inventory", () => { "default-inheritance", "unquoted-escape", "literal-tab", - "literal-tab-owned", + "literal-tab-missing", + "quoted-tab-owned", "case-alias", "short-alias", "owned", @@ -1004,8 +1070,9 @@ describe("security scan file inventory", () => { return; if (ownership === "unquoted-escape" && process.platform === "win32") return; - if (ownership.startsWith("literal-tab") && process.platform === "win32") - return; + const tabOwnership = + ownership.startsWith("literal-tab") || ownership === "quoted-tab-owned"; + if (tabOwnership && process.platform === "win32") return; if ( ownership === "short-alias" && (process.platform !== "win32" || python === null) @@ -1017,7 +1084,7 @@ describe("security scan file inventory", () => { checkout, ownership === "unquoted-escape" ? "nested\\towner" - : ownership.startsWith("literal-tab") + : tabOwnership ? "nested\towner" : "nested", ); @@ -1048,7 +1115,7 @@ describe("security scan file inventory", () => { ownership === "indented-override" || ownership === "default-inheritance" || ownership === "unquoted-escape" || - ownership.startsWith("literal-tab") + tabOwnership ? "false" : "true", ]); @@ -1106,15 +1173,14 @@ describe("security scan file inventory", () => { config, `${withoutOwner}\n[DEFAULT]\n\tworktree = ${nested}\n`, ); - } else if ( - ownership === "unquoted-escape" || - ownership.startsWith("literal-tab") - ) { + } else if (ownership === "unquoted-escape" || tabOwnership) { await writeFile( config, (await readFile(config, "utf8")).replace( /^([ \t]*worktree[ \t]*=).*$/im, - `$1 ${nested}`, + ownership === "quoted-tab-owned" + ? `$1 "${nested.replaceAll("\t", "\\t")}"` + : `$1 ${nested}`, ), ); } @@ -1130,28 +1196,8 @@ describe("security scan file inventory", () => { const alias = ownership === "case-alias" ? metadata.toUpperCase() - : execFileSync( - python!, - [ - "-B", - "-c", - [ - "import ctypes, sys", - "function = ctypes.windll.kernel32.GetShortPathNameW", - "size = function(sys.argv[1], None, 0)", - "buffer = ctypes.create_unicode_buffer(size) if size else None", - "print(buffer.value if buffer is not None and function(sys.argv[1], buffer, size) else '')", - ].join("\n"), - metadata, - ], - { encoding: "utf8" }, - ).trim(); - if ( - !alias || - (ownership === "short-alias" && - alias.toLowerCase() === metadata.toLowerCase()) - ) - return; + : windowsShortPath(metadata); + if (alias === null) return; const equivalent = await realpath(alias).then( async (resolved) => resolved === (await realpath(metadata)), () => false, @@ -1167,7 +1213,7 @@ describe("security scan file inventory", () => { ownership === "indented-override" || ownership === "default-inheritance" || ownership === "unquoted-escape" || - ownership === "literal-tab" + ownership.startsWith("literal-tab") ) { await expect(inventory(checkout)).rejects.toThrow( "Git metadata directory does not own selected worktree", @@ -1843,6 +1889,13 @@ describe("security scan file inventory", () => { .replace(/^gitdir: /, "") .trim(); await writeFile(join(gitdir, "objects"), "inactive worktree metadata\n"); + await mkdir(join(gitdir, "packed-refs")); + await writeFile( + join(gitdir, "refs", "heads"), + "inactive worktree references\n", + ); + await writeFile(join(gitdir, "config"), "[include]\npath = inactive\n"); + await mkdir(join(gitdir, "info", "exclude"), { recursive: true }); expect(await inventory(linked)).toContain("./visible.ts"); const alternateBackpointer = join( From 1ce28b5c0e3c235bfec3d22c459cc0e8c4a1924e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 08:27:10 -0700 Subject: [PATCH 083/106] fix(inventory): validate native Git config and incremental indexes --- .../scripts/generate_in_scope_files.py | 124 ++++++++------ .../tests-ts/scan-inventory.test.ts | 160 +++++++++++++++--- 2 files changed, 215 insertions(+), 69 deletions(-) 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 ae9ab50e..7ab39b7a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -215,6 +215,25 @@ def inspect_object_store(objects: Path) -> None: for member in entry.iterdir(): member_canonical = member.name.casefold() if canonical == "pack": + if member_canonical == "multi-pack-index.d": + if member.name != member_canonical and not aliases_canonical_path( + member, member_canonical + ): + continue + inspect_metadata(member, directory=True) + for layer in member.iterdir(): + layer_canonical = layer.name.casefold() + if layer_canonical != "multi-pack-index-chain" and not re.fullmatch( + r"multi-pack-index-[0-9a-f]{40}(?:[0-9a-f]{24})?\.(?:midx|bitmap|rev)", + layer_canonical, + ): + continue + if layer.name != layer_canonical and not aliases_canonical_path( + layer, layer_canonical + ): + continue + inspect_metadata(layer, directory=False) + continue if member_canonical == "multi-pack-index": expected = member_canonical elif not member_canonical.endswith( @@ -358,6 +377,33 @@ def join_config_lines(contents: bytes) -> bytes: position += 1 return bytes(joined) + def decode_config_value(value: str) -> tuple[bytes, bytes]: + decoded = bytearray() + normalized = bytearray() + quoted = False + escaped = False + replacements = {ord("n"): ord("\n"), ord("t"): ord("\t"), ord("b"): ord("\b")} + for character in os.fsencode(value): + if escaped: + if character not in (*replacements, ord('"'), ord("\\")): + raise InventoryError("invalid Git worktree path") + replacement = replacements.get(character, character) + decoded.append(replacement) + normalized.append(replacement) + escaped = False + elif character == ord("\\"): + escaped = True + elif character == ord('"'): + quoted = not quoted + else: + decoded.append(character) + normalized.append( + ord(" ") if character == ord("\t") and not quoted else character + ) + if quoted or escaped: + raise InventoryError("invalid Git worktree path") + return bytes(decoded), bytes(normalized) + options: dict[tuple[str, str], str | None] = {} config_path = roots[-1] / "config" worktree_config_enabled = False @@ -366,7 +412,11 @@ def join_config_lines(contents: bytes) -> bytes: for candidate in (config_path, gitdir / "config.worktree"): if candidate != config_path: extension = options.get(("extensions", "worktreeconfig"), "false") - normalized = "true" if extension is None else extension.strip().strip('"').casefold() + normalized = ( + "true" + if extension is None + else os.fsdecode(decode_config_value(extension)[0]).strip().casefold() + ) worktree_config_enabled = normalized not in ("", "false", "no", "off", "0") if not worktree_config_enabled: continue @@ -419,36 +469,13 @@ def join_config_lines(contents: bytes) -> bytes: if not backpointer_owned: raise InventoryError("Git metadata directory does not own selected worktree") else: - decoded = bytearray() - normalized = bytearray() - quoted = False - escaped = False - for character in os.fsencode(configured_worktree): - if escaped: - replacements = {ord("n"): ord("\n"), ord("t"): ord("\t"), ord("b"): ord("\b")} - if character not in (*replacements, ord('"'), ord("\\")): - raise InventoryError("invalid Git worktree path") - replacement = replacements.get(character, character) - decoded.append(replacement) - normalized.append(replacement) - escaped = False - elif character == ord("\\"): - escaped = True - elif character == ord('"'): - quoted = not quoted - else: - decoded.append(character) - normalized.append( - ord(" ") if character == ord("\t") and not quoted else character - ) - if quoted or escaped: - raise InventoryError("invalid Git worktree path") - configured_worktree = os.fsdecode(bytes(decoded)) + decoded, normalized = decode_config_value(configured_worktree) + configured_worktree = os.fsdecode(decoded) target = Path(configured_worktree) if not target.is_absolute(): target = gitdir / target if decoded != normalized: - normalized_target = Path(os.fsdecode(bytes(normalized))) + normalized_target = Path(os.fsdecode(normalized)) if not normalized_target.is_absolute(): normalized_target = gitdir / normalized_target try: @@ -558,31 +585,32 @@ def join_config_lines(contents: bytes) -> bytes: if not alternate.is_absolute(): alternate = object_root / alternate alternate = Path(os.path.abspath(alternate)) - owner = next( - ( - candidate - for candidate in (repository, *roots) - if len(alternate.parts) >= len(candidate.parts) - and all( - unicodedata.normalize("NFC", actual).casefold() - == unicodedata.normalize("NFC", expected).casefold() - for actual, expected in zip( - alternate.parts, candidate.parts - ) - ) - ), - None, - ) + owner = None anchor = alternate - if owner is not None: - for _ in range(len(alternate.parts) - len(owner.parts)): - anchor = anchor.parent - if owner is None or directory_identity(anchor) != directory_identity(owner): + for candidate in (repository, *roots): + if len(alternate.parts) < len(candidate.parts): + continue + candidate_anchor = alternate + for _ in range(len(alternate.parts) - len(candidate.parts)): + candidate_anchor = candidate_anchor.parent + try: + metadata = candidate_anchor.stat(follow_symlinks=False) + except FileNotFoundError: + continue + if symbolic_metadata(metadata) or ( + metadata.st_dev, + metadata.st_ino, + ) != directory_identity(candidate): + continue + owner = candidate + anchor = candidate_anchor + break + if owner is None: raise InventoryError( "external Git object alternates are not supported" ) - current = owner - for component in alternate.parts[len(owner.parts) :]: + current = anchor + for component in alternate.parts[len(anchor.parts) :]: current /= component if inspect_metadata(current, directory=True) is None: raise InventoryError( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 58db24f0..dc5c8688 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1048,6 +1048,7 @@ describe("security scan file inventory", () => { "disabled", "disabled-comment", "disabled-empty", + "disabled-quoted", "disabled-symlink", "hash-comment-override", "semicolon-comment-override", @@ -1138,13 +1139,18 @@ describe("security scan file inventory", () => { ); } else if ( ownership === "disabled-comment" || - ownership === "disabled-empty" + ownership === "disabled-empty" || + ownership === "disabled-quoted" ) { await writeFile( config, (await readFile(config, "utf8")).replace( /^([ \t]*worktreeConfig[ \t]*=[ \t]*)false$/im, - ownership === "disabled-comment" ? "$1false # disabled" : "$1", + ownership === "disabled-comment" + ? "$1false # disabled" + : ownership === "disabled-quoted" + ? '$1f"al"se' + : "$1", ), ); } else if (ownership.endsWith("comment-override")) { @@ -1189,6 +1195,8 @@ describe("security scan file inventory", () => { const unused = join(dirname(checkout), "unused.config"); await writeFile(unused, override); await symlink(unused, join(metadata, "config.worktree")); + } else if (ownership === "disabled-quoted") { + await mkdir(join(metadata, "config.worktree")); } else { await writeFile(join(metadata, "config.worktree"), override); } @@ -1332,6 +1340,109 @@ describe("security scan file inventory", () => { }, ); + test.each(["primary", "alternate"])( + "rejects symbolic incremental %s Git multi-pack-index directories", + async (owner) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const external = await repository(); + const objects = + owner === "primary" + ? join(checkout, ".git", "objects") + : join(checkout, ".git", "extra-objects"); + if (owner === "alternate") { + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${objects}\n`, + ); + } + const target = join( + external, + ".git", + "objects", + "pack", + "multi-pack-index.d", + ); + await mkdir(target); + await symlink( + target, + join(objects, "pack", "multi-pack-index.d"), + process.platform === "win32" ? "junction" : "dir", + ); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + + test + .skipIf(process.platform === "win32") + .each(["chain", "midx", "bitmap", "rev"])( + "rejects symbolic incremental Git multi-pack-index %s files", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const directory = join( + checkout, + ".git", + "objects", + "pack", + "multi-pack-index.d", + ); + const target = join(dirname(checkout), "external-index"); + await mkdir(directory); + await writeFile(target, "external\n"); + const name = + kind === "chain" + ? "multi-pack-index-chain" + : `multi-pack-index-${"a".repeat(40)}.${kind}`; + await symlink(target, join(directory, name)); + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + + test("inventories genuine incremental Git multi-pack indexes", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await writeFile(join(checkout, "visible.ts"), "tracked\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: checkout }); + commit(checkout); + execFileSync("git", ["repack", "-ad"], { cwd: checkout, stdio: "ignore" }); + try { + execFileSync("git", ["multi-pack-index", "write", "--incremental"], { + cwd: checkout, + stdio: "pipe", + }); + } catch (error) { + const stderr = String( + (error as Error & { stderr?: Buffer }).stderr ?? "", + ); + if (/unknown|unrecognized/i.test(stderr)) return; + throw error; + } + await mkdir( + join( + checkout, + ".git", + "objects", + "pack", + "multi-pack-index.d", + "unrelated-dir", + ), + ); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }); + test.skipIf(process.platform === "win32").each([ ["primary", "pack"], ["primary-uppercase", "pack"], @@ -1549,27 +1660,34 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); - test("allows case-equivalent contained Git object alternate paths", async () => { - if (Bun.which("rg") === null) return; + test.each(["case-alias", "short-alias"])( + "allows %s repository-owned Git object alternate paths", + async (kind) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const objects = join(checkout, ".git", "extra-objects"); - await mkdir(join(objects, "info"), { recursive: true }); - await mkdir(join(objects, "pack")); - const alias = objects.toUpperCase(); - const equivalent = await realpath(alias).then( - async (resolved) => resolved === (await realpath(objects)), - () => false, - ); - if (!equivalent) return; - await writeFile( - join(checkout, ".git", "objects", "info", "alternates"), - `${alias}\n`, - ); - await writeFile(join(checkout, "visible.ts"), "visible\n"); + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + const alias = + kind === "case-alias" + ? objects.toUpperCase() + : windowsShortPath(objects); + if (alias === null) return; + const equivalent = await realpath(alias).then( + async (resolved) => resolved === (await realpath(objects)), + () => false, + ); + if (!equivalent) return; + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${alias}\n`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); - expect(await inventory(checkout)).toContain("./visible.ts"); - }); + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); test("allows repository-owned transitive Git object alternates", async () => { if (Bun.which("rg") === null) return; From 7c7a8bdd0b5b70a3b13417fb485f695fba3b3ef8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 08:42:04 -0700 Subject: [PATCH 084/106] fix(inventory): validate alternate traversal and tracked file identity --- .../scripts/generate_in_scope_files.py | 37 ++++----- .../tests-ts/scan-inventory.test.ts | 76 ++++++++++++++++--- 2 files changed, 79 insertions(+), 34 deletions(-) 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 7ab39b7a..c44cdf4e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -584,15 +584,12 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: alternate = Path(os.fsdecode(line)) if not alternate.is_absolute(): alternate = object_root / alternate - alternate = Path(os.path.abspath(alternate)) owner = None anchor = alternate for candidate in (repository, *roots): if len(alternate.parts) < len(candidate.parts): continue - candidate_anchor = alternate - for _ in range(len(alternate.parts) - len(candidate.parts)): - candidate_anchor = candidate_anchor.parent + candidate_anchor = Path(*alternate.parts[: len(candidate.parts)]) try: metadata = candidate_anchor.stat(follow_symlinks=False) except FileNotFoundError: @@ -610,12 +607,23 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: "external Git object alternates are not supported" ) current = anchor + depth = 0 for component in alternate.parts[len(anchor.parts) :]: + if component == "..": + if depth == 0: + raise InventoryError( + "external Git object alternates are not supported" + ) + current = current.parent + depth -= 1 + continue current /= component if inspect_metadata(current, directory=True) is None: raise InventoryError( "missing Git object alternates are not supported" ) + depth += 1 + alternate = current identity = directory_identity(alternate) if identity in inspected: continue @@ -1365,15 +1373,6 @@ def visible_nested_root(root: Path) -> bool: root, [relative for relative in tracked.stdout.split(b"\0") if relative], ) - case_insensitive_roots: dict[tuple[int, int], bool] = {} - for identity, (root, _) in cached_by_root.items(): - setting = run_git(["config", "--bool", "core.ignoreCase"], directory=root) - if setting.returncode not in (0, 1): - detail = setting.stderr.decode("utf-8", errors="replace").strip() - raise InventoryError( - f"git config exited with status {setting.returncode}: {detail}" - ) - case_insensitive_roots[identity] = setting.stdout.strip().lower() == b"true" recorded = {normalized(row.removesuffix(b"\n")) for row in rows} directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} @@ -1435,12 +1434,7 @@ def tracked_variants( return indexed_name = os.fsdecode(indexed) requested_name = os.fsdecode(requested) - if unicodedata.normalize("NFC", indexed_name) != unicodedata.normalize( - "NFC", requested_name - ) and ( - not case_insensitive_roots[root_identity] - or indexed_name_key(indexed_name) != indexed_name_key(requested_name) - ): + if indexed_name_key(indexed_name) != indexed_name_key(requested_name): return def descend(parent: Path, index: int) -> list[Path]: @@ -1469,11 +1463,6 @@ def descend(parent: Path, index: int) -> list[Path]: candidate for candidate in variants if candidate.name != component - and ( - case_insensitive_roots[root_identity] - or unicodedata.normalize("NFC", candidate.name) - == unicodedata.normalize("NFC", component) - ) ] for group in (exact, alternatives): matches: list[Path] = [] diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index dc5c8688..d3a40c0f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -362,16 +362,17 @@ describe("security scan file inventory", () => { (await inventory(checkout)).includes(`./${replacement}/private.ts`), ).toBe(expected); - if (indexed === "caf\u00e9") { - execFileSync("git", ["config", "core.ignoreCase", "false"], { - cwd: checkout, - }); - expect( - (await inventory(checkout, replacement)).includes( - `${replacement}/private.ts`, - ), - ).toBe(expected); - } + execFileSync("git", ["config", "core.ignoreCase", "false"], { + cwd: checkout, + }); + expect( + (await inventory(checkout)).includes(`./${replacement}/private.ts`), + ).toBe(expected); + expect( + (await inventory(checkout, replacement)).includes( + `${replacement}/private.ts`, + ), + ).toBe(expected); }, ); @@ -1604,6 +1605,45 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test.skipIf(process.platform === "win32").each(["primary", "transitive"])( + "rejects symbolic %s Git object-alternate hops before parent traversal", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const outside = join(dirname(checkout), "outside"); + const external = join(outside, "store"); + const decoy = join(checkout, ".git", "store"); + const hop = join(checkout, ".git", "hop"); + await mkdir(join(outside, "hop-target"), { recursive: true }); + for (const objects of [external, decoy]) { + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + } + await symlink(join(outside, "hop-target"), hop); + const alternate = `${hop}/../store`; + if (kind === "primary") { + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${alternate}\n`, + ); + } else { + const first = join(checkout, ".git", "first-objects"); + await mkdir(join(first, "info"), { recursive: true }); + await mkdir(join(first, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${first}\n`, + ); + await writeFile(join(first, "info", "alternates"), `${alternate}\n`); + } + + await expect(inventory(checkout)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test.each(["\\x61", "\\400"])( "rejects quoted Git object alternates with unsupported escape %s", async (escape) => { @@ -1660,6 +1700,22 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); + test("allows safe parent traversal to repository-owned Git object alternates", async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const objects = join(checkout, ".git", "extra-objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + "../extra-objects\n", + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }); + test.each(["case-alias", "short-alias"])( "allows %s repository-owned Git object alternate paths", async (kind) => { From 192fa92e97772808c47a2e4ce99ac0448fd3c04f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 08:58:42 -0700 Subject: [PATCH 085/106] fix(inventory): validate Git pointer traversal and conflicted Gitlinks --- .../scripts/generate_in_scope_files.py | 79 ++++++---- .../tests-ts/scan-inventory.test.ts | 145 ++++++++++++++---- 2 files changed, 167 insertions(+), 57 deletions(-) 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 c44cdf4e..998cd01a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -152,11 +152,12 @@ def directory_identity(path: Path) -> tuple[int, int]: return metadata.st_dev, metadata.st_ino def same_filesystem_path(first: Path, second: Path) -> bool: - return tuple(unicodedata.normalize("NFC", part).casefold() for part in first.parts) == tuple( - unicodedata.normalize("NFC", part).casefold() for part in second.parts - ) and directory_identity(first) == directory_identity(second) and directory_identity( - first.parent - ) == directory_identity(second.parent) + try: + return directory_identity(first) == directory_identity(second) and directory_identity( + first.parent + ) == directory_identity(second.parent) + except FileNotFoundError: + return False def nonsymbolic_directory(path: Path) -> bool: try: @@ -188,6 +189,26 @@ def inspect_metadata( raise InventoryError("non-regular Git metadata files are not supported") return metadata + def inspect_metadata_path(path: Path, *, directory_path: bool | None) -> Path | None: + current = Path(path.anchor) + if inspect_metadata(current, directory=True) is None: + return None + components = path.parts[1:] + for index, component in enumerate(components): + if component == "..": + current = current.parent + continue + current /= component + if ( + inspect_metadata( + current, + directory=directory_path if index + 1 == len(components) else True, + ) + is None + ): + return None + return current + def aliases_canonical_path(path: Path, canonical: str) -> bool: try: actual = path.stat(follow_symlinks=False) @@ -278,10 +299,10 @@ def inspect_object_store(objects: Path) -> None: gitdir = Path(os.fsdecode(contents.removeprefix(b"gitdir: ").rstrip(b"\r\n"))) if not gitdir.is_absolute(): gitdir = directory / gitdir - gitdir = Path(os.path.abspath(gitdir)) - for current in reversed((gitdir, *gitdir.parents)): - if inspect_metadata(current, directory=True) is None: - break + inspected_gitdir = inspect_metadata_path(gitdir, directory_path=True) + if inspected_gitdir is None: + raise InventoryError("Git metadata directory does not own selected worktree") + gitdir = inspected_gitdir internally_owned = len(gitdir.parts) >= len(repository.parts) if internally_owned: ancestor = gitdir @@ -297,7 +318,8 @@ def inspect_object_store(objects: Path) -> None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if not target.is_absolute(): target = gitdir / target - if not same_filesystem_path(Path(os.path.abspath(target)), marker): + inspected_target = inspect_metadata_path(target, directory_path=None) + if inspected_target is None or not same_filesystem_path(inspected_target, marker): raise InventoryError("Git metadata directory does not own selected worktree") elif not internally_owned: raise InventoryError("Git metadata directory does not own selected worktree") @@ -322,13 +344,13 @@ def inspect_object_store(objects: Path) -> None: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if not common.is_absolute(): common = gitdir / common - common = Path(os.path.abspath(common)) + inspected_common = inspect_metadata_path(common, directory_path=True) + if inspected_common is None: + raise InventoryError("Git common directory does not own selected worktree") + common = inspected_common owner = common / "worktrees" / gitdir.name if not same_filesystem_path(owner, gitdir): raise InventoryError("Git common directory does not own selected worktree") - for current in reversed((common, *common.parents)): - if inspect_metadata(current, directory=True) is None: - break roots.append(common) def config_value(value: str | None) -> str | None: @@ -474,25 +496,26 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: target = Path(configured_worktree) if not target.is_absolute(): target = gitdir / target + inspected_target = inspect_metadata_path(target, directory_path=True) + if inspected_target is None: + raise InventoryError("Git metadata directory does not own selected worktree") if decoded != normalized: normalized_target = Path(os.fsdecode(normalized)) if not normalized_target.is_absolute(): normalized_target = gitdir / normalized_target - try: - normalized_metadata = normalized_target.stat(follow_symlinks=False) - except FileNotFoundError: + inspected_normalized = inspect_metadata_path( + normalized_target, + directory_path=True, + ) + if inspected_normalized is None: raise InventoryError( "Git metadata directory does not own selected worktree" - ) from None - else: - if symbolic_metadata(normalized_metadata) or ( - normalized_metadata.st_dev, - normalized_metadata.st_ino, - ) != directory_identity(directory): - raise InventoryError( - "Git metadata directory does not own selected worktree" - ) - if not same_filesystem_path(Path(os.path.abspath(target)), directory): + ) + if directory_identity(inspected_normalized) != directory_identity(directory): + raise InventoryError( + "Git metadata directory does not own selected worktree" + ) + if not same_filesystem_path(inspected_target, directory): raise InventoryError("Git metadata directory does not own selected worktree") for root in roots: @@ -1403,7 +1426,7 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: and (header := parts[0].split()) and len(header) == 3 and header[0] == b"160000" - and header[2] == b"0" + and header[2] in (b"0", b"1", b"2", b"3") for path in (parts[2],) } tracked_gitlinks.extend( diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d3a40c0f..bca71eb7 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -566,34 +566,51 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); - test("admits tracked Gitlinks through configured directory excludes", async () => { - if (Bun.which("rg") === null) return; + test.each(["stage-0", "conflicted"])( + "admits %s tracked Gitlinks through configured directory excludes", + async (staging) => { + if (Bun.which("rg") === null) return; - const checkout = await repository(); - const nested = join(checkout, "nested"); - await mkdir(nested); - execFileSync("git", ["init", "-q"], { cwd: nested }); - await Promise.all([ - writeFile(join(nested, "visible.ts"), "visible\n"), - writeFile(join(nested, "private.ts"), "private\n"), - ]); - execFileSync("git", ["add", "visible.ts", "private.ts"], { - cwd: nested, - }); - commit(nested); - execFileSync("git", ["add", "nested"], { - cwd: checkout, - stdio: "ignore", - }); - await writeFile( - join(checkout, ".git", "info", "exclude"), - "nested/\nnested/private.ts\n", - ); + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, "visible.ts"), "visible\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + execFileSync("git", ["add", "visible.ts", "private.ts"], { + cwd: nested, + }); + commit(nested); + execFileSync("git", ["add", "nested"], { + cwd: checkout, + stdio: "ignore", + }); + if (staging === "conflicted") { + const object = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: nested, + encoding: "utf8", + }).trim(); + execFileSync("git", ["update-index", "--index-info"], { + cwd: checkout, + input: [ + `0 ${"0".repeat(40)}\tnested`, + ...[1, 2, 3].map((stage) => `160000 ${object} ${stage}\tnested`), + "", + ].join("\n"), + }); + } + await writeFile( + join(checkout, ".git", "info", "exclude"), + "nested/\nnested/private.ts\n", + ); - const rows = await inventory(checkout); - expect(rows).toContain("./nested/visible.ts"); - expect(rows).not.toContain("./nested/private.ts"); - }); + const rows = await inventory(checkout); + expect(rows).toContain("./nested/visible.ts"); + expect(rows).not.toContain("./nested/private.ts"); + }, + ); test.skipIf(process.platform === "win32").each([".ignore", ".rgignore"])( "rejects a hidden Gitlink ancestor's symbolic %s", @@ -988,6 +1005,65 @@ describe("security scan file inventory", () => { }, ); + test.each(["gitdir", "backpointer", "commondir", "worktree"])( + "rejects symbolic %s metadata hops before parent traversal", + async (kind) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + await writeFile(join(checkout, "visible.ts"), "tracked\n"); + execFileSync("git", ["add", "visible.ts"], { cwd: checkout }); + commit(checkout); + const linked = join(dirname(checkout), "linked-worktree"); + execFileSync("git", ["worktree", "add", "--detach", linked, "HEAD"], { + cwd: checkout, + stdio: "ignore", + }); + const gitdir = (await readFile(join(linked, ".git"), "utf8")) + .replace(/^gitdir: /, "") + .trim(); + const outside = join(dirname(checkout), "outside", "hop-target"); + await mkdir(outside, { recursive: true }); + const hop = + kind === "gitdir" + ? join(dirname(gitdir), "hop") + : kind === "commondir" + ? join(checkout, "hop") + : join(linked, "hop"); + await symlink( + outside, + hop, + process.platform === "win32" ? "junction" : "dir", + ); + + if (kind === "gitdir") { + await writeFile( + join(linked, ".git"), + `gitdir: ${hop}/../${basename(gitdir)}\n`, + ); + } else if (kind === "backpointer") { + await writeFile(join(gitdir, "gitdir"), `${hop}/../.git\n`); + } else if (kind === "commondir") { + await writeFile(join(gitdir, "commondir"), `${hop}/../.git\n`); + } else { + execFileSync("git", ["config", "extensions.worktreeConfig", "true"], { + cwd: linked, + }); + execFileSync( + "git", + ["config", "--worktree", "core.worktree", `${hop}/..`], + { + cwd: linked, + }, + ); + } + + await expect(inventory(linked)).rejects.toThrow( + "symbolic Git metadata paths are not supported", + ); + }, + ); + test.each(["missing", "mismatched"])( "rejects an external gitdir with a %s worktree backpointer", async (ownership) => { @@ -1061,6 +1137,7 @@ describe("security scan file inventory", () => { "quoted-tab-owned", "case-alias", "short-alias", + "short-worktree", "owned", "external", "mixed-case-external", @@ -1076,7 +1153,7 @@ describe("security scan file inventory", () => { ownership.startsWith("literal-tab") || ownership === "quoted-tab-owned"; if (tabOwnership && process.platform === "win32") return; if ( - ownership === "short-alias" && + ownership.startsWith("short-") && (process.platform !== "win32" || python === null) ) return; @@ -1126,7 +1203,8 @@ describe("security scan file inventory", () => { const effective = ownership === "owned" || ownership === "case-alias" || - ownership === "short-alias" + ownership === "short-alias" || + ownership === "short-worktree" ? nested : external; const config = join(metadata, "config"); @@ -1191,7 +1269,10 @@ describe("security scan file inventory", () => { ), ); } - const override = `[${ownership === "mixed-case-external" ? "Core" : "core"}]\n\tworktree = ${effective}\n`; + const configuredOwner = + ownership === "short-worktree" ? windowsShortPath(nested) : effective; + if (configuredOwner === null) return; + const override = `[${ownership === "mixed-case-external" ? "Core" : "core"}]\n\tworktree = ${configuredOwner}\n`; if (ownership === "disabled-symlink") { const unused = join(dirname(checkout), "unused.config"); await writeFile(unused, override); @@ -2072,6 +2153,12 @@ describe("security scan file inventory", () => { await mkdir(join(gitdir, "info", "exclude"), { recursive: true }); expect(await inventory(linked)).toContain("./visible.ts"); + const shortBackpointer = windowsShortPath(join(linked, ".git")); + if (shortBackpointer !== null) { + await writeFile(join(gitdir, "gitdir"), `${shortBackpointer}\n`); + expect(await inventory(linked)).toContain("./visible.ts"); + } + const alternateBackpointer = join( dirname(linked), "LINKED-WORKTREE", From 5bf12fbb596b9cb78739f99f8df8022d4bd9a224 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 09:45:04 -0700 Subject: [PATCH 086/106] fix(inventory): parse native Git configuration whitespace --- .../scripts/generate_in_scope_files.py | 12 ++-- .../tests-ts/scan-inventory.test.ts | 55 ++++++++++++++++--- 2 files changed, 53 insertions(+), 14 deletions(-) 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 998cd01a..9524bf66 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -17,7 +17,7 @@ from pathlib import Path, PurePosixPath IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") -GIT_CONFIG_INCLUDE = re.compile(rb"(?im)^[ \t]*\[[ \t]*include(?:if)?(?=[ \t\]])") +GIT_CONFIG_INCLUDE = re.compile(rb"(?im)^[ \t\r]*\[[ \t]*include(?:if)?(?=[ \t\]])") class InventoryError(ValueError): @@ -366,8 +366,8 @@ def config_value(value: str | None) -> str | None: elif character == '"': quoted = not quoted elif character in "#;" and not quoted: - return value[:index].rstrip() - return value.rstrip() + return value[:index].rstrip(" \t\r") + return value.rstrip(" \t\r") def join_config_lines(contents: bytes) -> bytes: joined = bytearray() @@ -437,7 +437,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: normalized = ( "true" if extension is None - else os.fsdecode(decode_config_value(extension)[0]).strip().casefold() + else os.fsdecode(decode_config_value(extension)[0]).strip(" \t\r").casefold() ) worktree_config_enabled = normalized not in ("", "false", "no", "off", "0") if not worktree_config_enabled: @@ -448,7 +448,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: contents = join_config_lines(contents) section = None for raw in os.fsdecode(contents).split("\n"): - line = raw.lstrip(" \t").rstrip("\r") + line = raw.lstrip(" \t\r").rstrip("\r") if not line or line.startswith(("#", ";")): continue if line.startswith("["): @@ -462,7 +462,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: if section is None: continue assignment = re.match( - r"([a-z][a-z0-9-]*)(?:([ \t]*=)[ \t]*(.*))?", + r"([a-z][a-z0-9-]*)(?:([ \t]*=)[ \t\r]*(.*))?", line, re.IGNORECASE, ) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index bca71eb7..c350c01b 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1126,15 +1126,20 @@ describe("security scan file inventory", () => { "disabled-comment", "disabled-empty", "disabled-quoted", + "disabled-carriage", "disabled-symlink", "hash-comment-override", "semicolon-comment-override", "indented-override", + "carriage-return-override", + "carriage-return-section", "default-inheritance", "unquoted-escape", "literal-tab", "literal-tab-missing", "quoted-tab-owned", + "vertical-tab-owned", + "form-feed-owned", "case-alias", "short-alias", "short-worktree", @@ -1151,7 +1156,10 @@ describe("security scan file inventory", () => { return; const tabOwnership = ownership.startsWith("literal-tab") || ownership === "quoted-tab-owned"; - if (tabOwnership && process.platform === "win32") return; + const controlOwnership = + ownership === "vertical-tab-owned" || ownership === "form-feed-owned"; + if ((tabOwnership || controlOwnership) && process.platform === "win32") + return; if ( ownership.startsWith("short-") && (process.platform !== "win32" || python === null) @@ -1165,7 +1173,11 @@ describe("security scan file inventory", () => { ? "nested\\towner" : tabOwnership ? "nested\towner" - : "nested", + : ownership === "vertical-tab-owned" + ? "nested\vowner" + : ownership === "form-feed-owned" + ? "nested\fowner" + : "nested", ); const metadata = join(checkout, ".git", "modules", "nested"); const external = @@ -1192,9 +1204,11 @@ describe("security scan file inventory", () => { ownership.startsWith("disabled") || ownership.endsWith("comment-override") || ownership === "indented-override" || + ownership.startsWith("carriage-return") || ownership === "default-inheritance" || ownership === "unquoted-escape" || - tabOwnership + tabOwnership || + controlOwnership ? "false" : "true", ]); @@ -1219,7 +1233,8 @@ describe("security scan file inventory", () => { } else if ( ownership === "disabled-comment" || ownership === "disabled-empty" || - ownership === "disabled-quoted" + ownership === "disabled-quoted" || + ownership === "disabled-carriage" ) { await writeFile( config, @@ -1229,7 +1244,9 @@ describe("security scan file inventory", () => { ? "$1false # disabled" : ownership === "disabled-quoted" ? '$1f"al"se' - : "$1", + : ownership === "disabled-carriage" + ? "$1\rfalse" + : "$1", ), ); } else if (ownership.endsWith("comment-override")) { @@ -1249,6 +1266,19 @@ describe("security scan file inventory", () => { `$1 # selected owner\n\t\tworktree = ${external}`, ), ); + } else if (ownership === "carriage-return-override") { + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=.*)$/im, + `$1\n\rworktree = ${external}`, + ), + ); + } else if (ownership === "carriage-return-section") { + await writeFile( + config, + `${await readFile(config, "utf8")}\n\r[core]\n\tworktree = ${external}\n`, + ); } else if (ownership === "default-inheritance") { const withoutOwner = (await readFile(config, "utf8")).replace( /^[ \t]*worktree[ \t]*=.*\n/im, @@ -1258,7 +1288,11 @@ describe("security scan file inventory", () => { config, `${withoutOwner}\n[DEFAULT]\n\tworktree = ${nested}\n`, ); - } else if (ownership === "unquoted-escape" || tabOwnership) { + } else if ( + ownership === "unquoted-escape" || + tabOwnership || + controlOwnership + ) { await writeFile( config, (await readFile(config, "utf8")).replace( @@ -1277,7 +1311,10 @@ describe("security scan file inventory", () => { const unused = join(dirname(checkout), "unused.config"); await writeFile(unused, override); await symlink(unused, join(metadata, "config.worktree")); - } else if (ownership === "disabled-quoted") { + } else if ( + ownership === "disabled-quoted" || + ownership === "disabled-carriage" + ) { await mkdir(join(metadata, "config.worktree")); } else { await writeFile(join(metadata, "config.worktree"), override); @@ -1301,6 +1338,7 @@ describe("security scan file inventory", () => { ownership === "mixed-case-external" || ownership.endsWith("comment-override") || ownership === "indented-override" || + ownership.startsWith("carriage-return") || ownership === "default-inheritance" || ownership === "unquoted-escape" || ownership.startsWith("literal-tab") @@ -2022,6 +2060,7 @@ describe("security scan file inventory", () => { ["include", "without BOM"], ['includeIf "gitdir:**"', "without BOM"], ["include", "with BOM"], + ["include", "with leading carriage return"], ])( "rejects repository-directed %s config %s before invoking Git", async (section, bom) => { @@ -2033,7 +2072,7 @@ describe("security scan file inventory", () => { const config = join(checkout, ".git", "config"); await writeFile( config, - `${bom === "with BOM" ? "\ufeff" : ""}[${section}]\n\tpath = ${external}\n${await readFile(config, "utf8")}`, + `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${section}]\n\tpath = ${external}\n${await readFile(config, "utf8")}`, ); await expect(inventory(checkout)).rejects.toThrow( From c80ce15e394e096e97f9c354e9ebbd1ccd60f389 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 10:12:51 -0700 Subject: [PATCH 087/106] fix(inventory): validate native Git config and metadata paths --- .../scripts/generate_in_scope_files.py | 112 +++++++++------ .../tests-ts/scan-inventory.test.ts | 127 +++++++++++++++++- 2 files changed, 192 insertions(+), 47 deletions(-) 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 9524bf66..e6914ef7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -17,7 +17,6 @@ from pathlib import Path, PurePosixPath IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") -GIT_CONFIG_INCLUDE = re.compile(rb"(?im)^[ \t\r]*\[[ \t]*include(?:if)?(?=[ \t\]])") class InventoryError(ValueError): @@ -175,6 +174,12 @@ def has_git_marker(directory: Path) -> bool: def inspect_metadata( path: Path, *, directory: bool | None = None ) -> os.stat_result | None: + if ( + os.name == "nt" + and path.anchor.startswith("\\\\") + and os.path.normcase(path.anchor) != os.path.normcase(repository.anchor) + ): + raise InventoryError("network Git metadata paths are not supported") try: metadata = path.stat(follow_symlinks=False) except FileNotFoundError: @@ -429,6 +434,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: options: dict[tuple[str, str], str | None] = {} config_path = roots[-1] / "config" worktree_config_enabled = False + config_includes = False if inspect_metadata(config_path, directory=False) is not None: try: for candidate in (config_path, gitdir / "config.worktree"): @@ -453,11 +459,17 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: continue if line.startswith("["): match = re.match( - r"\[[ \t]*(core|extensions)[ \t]*\](?=[ \t]*(?:[#;]|$))", + r'\[[ \t]*(core|extensions|include|includeif)' + r'(?:[ \t]+("(?:[^"\\]|\\.)*"))?[ \t]*\]' + r'(?=[ \t\r]*(?:[#;]|$))', line, re.IGNORECASE, ) - section = None if match is None else match.group(1).casefold() + section = None + if match is not None: + name = match.group(1).casefold() + if (match.group(2) is not None) == (name == "includeif"): + section = name continue if section is None: continue @@ -473,6 +485,8 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: if remainder and not remainder.startswith(("#", ";")): continue key = assignment.group(1).casefold() + if section in ("include", "includeif") and key == "path": + config_includes = True if (section, key) in ( ("core", "worktree"), ("extensions", "worktreeconfig"), @@ -484,6 +498,8 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: ) except (OSError, UnicodeError, ValueError) as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error + if config_includes: + raise InventoryError("Git config includes are not supported") configured_worktree = options.get(("core", "worktree")) if gitfile: @@ -573,23 +589,30 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: raise InventoryError( f"could not inspect Git metadata: {directory}" ) from error - if metadata is not None and relative in ( - "config", - "config.worktree", - "objects/info/alternates", - ): + if metadata is not None and relative == "objects/info/alternates": try: contents = path.read_bytes() except OSError as error: raise InventoryError(f"could not inspect Git metadata: {directory}") from error - if relative == "objects/info/alternates": - pending = [(root / "objects", contents)] - inspected = {directory_identity(root / "objects")} - while pending: - object_root, records = pending.pop() - for line in records.split(b"\n"): - if not line: - continue + pending = [(root / "objects", contents)] + inspected = {directory_identity(root / "objects")} + while pending: + object_root, records = pending.pop() + lines = records.split(b"\n") + for index, record in enumerate(lines): + terminated = index + 1 < len(lines) + if not record or (terminated and record == b"\r"): + continue + if record.startswith(b'"'): + variants = (record.removesuffix(b"\r"),) + elif terminated and record.endswith(b"\r"): + variants = (record.removesuffix(b"\r"),) + if os.name != "nt": + variants += (record,) + else: + variants = (record,) + validated = False + for line in variants: if line.startswith(b'"'): if not line.endswith(b'"'): raise InventoryError("invalid Git object alternate paths") @@ -613,11 +636,10 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: if len(alternate.parts) < len(candidate.parts): continue candidate_anchor = Path(*alternate.parts[: len(candidate.parts)]) - try: - metadata = candidate_anchor.stat(follow_symlinks=False) - except FileNotFoundError: + metadata = inspect_metadata(candidate_anchor, directory=True) + if metadata is None: continue - if symbolic_metadata(metadata) or ( + if ( metadata.st_dev, metadata.st_ino, ) != directory_identity(candidate): @@ -642,31 +664,33 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: continue current /= component if inspect_metadata(current, directory=True) is None: - raise InventoryError( - "missing Git object alternates are not supported" - ) + break depth += 1 - alternate = current - identity = directory_identity(alternate) - if identity in inspected: - continue - inspected.add(identity) - inspect_object_store(alternate) - info = alternate / "info" - if inspect_metadata(info, directory=True) is None: - continue - nested_alternates = info / "alternates" - if inspect_metadata(nested_alternates, directory=False) is None: - continue - try: - records = nested_alternates.read_bytes() - except OSError as error: - raise InventoryError( - f"could not inspect Git metadata: {directory}" - ) from error - pending.append((alternate, records)) - elif GIT_CONFIG_INCLUDE.search(contents.removeprefix(b"\xef\xbb\xbf")): - raise InventoryError("Git config includes are not supported") + else: + validated = True + alternate = current + identity = directory_identity(alternate) + if identity in inspected: + continue + inspected.add(identity) + inspect_object_store(alternate) + info = alternate / "info" + if inspect_metadata(info, directory=True) is None: + continue + nested_alternates = info / "alternates" + if inspect_metadata(nested_alternates, directory=False) is None: + continue + try: + records = nested_alternates.read_bytes() + except OSError as error: + raise InventoryError( + f"could not inspect Git metadata: {directory}" + ) from error + pending.append((alternate, records)) + if not validated: + raise InventoryError( + "missing Git object alternates are not supported" + ) if root != gitdir: continue try: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index c350c01b..91646eae 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1064,6 +1064,80 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform !== "win32").each([ + ["gitdir", "network"], + ["gitdir", "device"], + ["backpointer", "network"], + ["commondir", "network"], + ["worktree", "network"], + ["alternates", "network"], + ])( + "rejects %s %s metadata before accessing its Windows anchor", + async (kind, prefix) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(kind !== "gitdir"); + const remote = `${ + prefix === "device" ? "\\\\?\\UNC" : "\\" + }\\codex-security.invalid\\share\\one\\two\\three\\four\\five\\six\\seven\\eight`; + let selected = checkout; + if (kind === "gitdir") { + await writeFile(join(checkout, ".git"), `gitdir: ${remote}\n`); + } else if (kind === "alternates") { + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${remote}\n`, + ); + } else { + await writeFile(join(checkout, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + commit(checkout); + selected = join(dirname(checkout), "linked-worktree"); + execFileSync("git", ["worktree", "add", "--detach", selected, "HEAD"], { + cwd: checkout, + stdio: "ignore", + }); + const gitdir = (await readFile(join(selected, ".git"), "utf8")) + .replace(/^gitdir: /, "") + .trim(); + if (kind === "backpointer") { + await writeFile(join(gitdir, "gitdir"), `${remote}\n`); + } else if (kind === "commondir") { + await writeFile(join(gitdir, "commondir"), `${remote}\n`); + } else { + execFileSync("git", ["config", "extensions.worktreeConfig", "true"], { + cwd: selected, + }); + await writeFile( + join(gitdir, "config.worktree"), + `[core]\n\tworktree = ${JSON.stringify(remote)}\n`, + ); + } + } + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self.anchor.startswith(chr(92) * 2) and 'codex-security.invalid' in str(self).casefold():", + " raise RuntimeError('attempted network metadata access')", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + + await expect( + inventory(selected, ".", { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).rejects.toThrow("network Git metadata paths are not supported"); + }, + ); + test.each(["missing", "mismatched"])( "rejects an external gitdir with a %s worktree backpointer", async (ownership) => { @@ -1133,6 +1207,7 @@ describe("security scan file inventory", () => { "indented-override", "carriage-return-override", "carriage-return-section", + "carriage-return-section-comment", "default-inheritance", "unquoted-escape", "literal-tab", @@ -1274,10 +1349,17 @@ describe("security scan file inventory", () => { `$1\n\rworktree = ${external}`, ), ); - } else if (ownership === "carriage-return-section") { + } else if ( + ownership === "carriage-return-section" || + ownership === "carriage-return-section-comment" + ) { await writeFile( config, - `${await readFile(config, "utf8")}\n\r[core]\n\tworktree = ${external}\n`, + `${await readFile(config, "utf8")}\n${ + ownership === "carriage-return-section" + ? "\r[core]" + : "[core]\r# selected owner" + }\n\tworktree = ${external}\n`, ); } else if (ownership === "default-inheritance") { const withoutOwner = (await readFile(config, "utf8")).replace( @@ -1819,6 +1901,25 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); + test.each(["quoted", "unquoted"])( + "allows %s CRLF-terminated repository-owned Git object alternates", + async (format) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const objects = join(checkout, ".git", "extra objects"); + await mkdir(join(objects, "info"), { recursive: true }); + await mkdir(join(objects, "pack")); + await writeFile( + join(checkout, ".git", "objects", "info", "alternates"), + `${format === "quoted" ? JSON.stringify(objects) : objects}\r\n`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }, + ); + test("allows safe parent traversal to repository-owned Git object alternates", async () => { if (Bun.which("rg") === null) return; @@ -2061,6 +2162,7 @@ describe("security scan file inventory", () => { ['includeIf "gitdir:**"', "without BOM"], ["include", "with BOM"], ["include", "with leading carriage return"], + ["include", "with carriage return after header"], ])( "rejects repository-directed %s config %s before invoking Git", async (section, bom) => { @@ -2072,7 +2174,7 @@ describe("security scan file inventory", () => { const config = join(checkout, ".git", "config"); await writeFile( config, - `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${section}]\n\tpath = ${external}\n${await readFile(config, "utf8")}`, + `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${section}]${bom === "with carriage return after header" ? "\r# included" : ""}\n\tpath = ${external}\n${await readFile(config, "utf8")}`, ); await expect(inventory(checkout)).rejects.toThrow( @@ -2081,6 +2183,25 @@ describe("security scan file inventory", () => { }, ); + test.each([ + ["include", "foo = bar"], + ["include", "# path = ignored"], + ['includeIf "gitdir:**"', "foo = bar"], + ['include "inactive"', "path = ignored"], + ])("allows inert [%s] Git config sections", async (section, assignment) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const config = join(checkout, ".git", "config"); + await writeFile( + config, + `[${section}]\n\t${assignment}\n${await readFile(config, "utf8")}`, + ); + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + expect(await inventory(checkout)).toContain("./visible.ts"); + }); + test .skipIf(process.platform === "win32") .each(["config", "info/sparse-checkout"])( From a3577700f0d774a88ea18870ada0af7d94b6cb98 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 10:24:12 -0700 Subject: [PATCH 088/106] fix(inventory): reject carriage-return conditional includes --- .../scripts/generate_in_scope_files.py | 2 +- .../tests-ts/scan-inventory.test.ts | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) 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 e6914ef7..87fbaa10 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -460,7 +460,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: if line.startswith("["): match = re.match( r'\[[ \t]*(core|extensions|include|includeif)' - r'(?:[ \t]+("(?:[^"\\]|\\.)*"))?[ \t]*\]' + r'(?:[ \t\r]+("(?:[^"\\]|\\.)*"))?[ \t]*\]' r'(?=[ \t\r]*(?:[#;]|$))', line, re.IGNORECASE, diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 91646eae..4afc5d25 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1074,8 +1074,6 @@ describe("security scan file inventory", () => { ])( "rejects %s %s metadata before accessing its Windows anchor", async (kind, prefix) => { - if (Bun.which("rg") === null) return; - const checkout = await repository(kind !== "gitdir"); const remote = `${ prefix === "device" ? "\\\\?\\UNC" : "\\" @@ -2163,6 +2161,15 @@ describe("security scan file inventory", () => { ["include", "with BOM"], ["include", "with leading carriage return"], ["include", "with carriage return after header"], + ['includeIf "gitdir:**"', "with carriage return before condition"], + [ + 'includeIf "gitdir:**"', + "with carriage return after condition whitespace", + ], + [ + 'includeIf "gitdir:**"', + "with carriage return replacing condition whitespace", + ], ])( "rejects repository-directed %s config %s before invoking Git", async (section, bom) => { @@ -2172,9 +2179,18 @@ describe("security scan file inventory", () => { const external = join(dirname(checkout), "external.config"); await writeFile(external, "[core]\n\tignoreCase = true\n"); const config = join(checkout, ".git", "config"); + const whitespace = + bom === "with carriage return before condition" + ? "\r " + : bom === "with carriage return after condition whitespace" + ? " \r" + : bom === "with carriage return replacing condition whitespace" + ? "\r" + : " "; + const configuredSection = section.replace(" ", whitespace); await writeFile( config, - `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${section}]${bom === "with carriage return after header" ? "\r# included" : ""}\n\tpath = ${external}\n${await readFile(config, "utf8")}`, + `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${configuredSection}]${bom === "with carriage return after header" ? "\r# included" : ""}\n\tpath = ${external}\n${await readFile(config, "utf8")}`, ); await expect(inventory(checkout)).rejects.toThrow( From 0e694bdc2d1bf3e41ebb41bebd36a8a6a3bd1c04 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 10:38:17 -0700 Subject: [PATCH 089/106] fix(inventory): normalize Git worktree carriage returns --- .../scripts/generate_in_scope_files.py | 4 +- .../tests-ts/scan-inventory.test.ts | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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 87fbaa10..118343c9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -425,7 +425,9 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: else: decoded.append(character) normalized.append( - ord(" ") if character == ord("\t") and not quoted else character + ord(" ") + if character in (ord("\t"), ord("\r")) and not quoted + else character ) if quoted or escaped: raise InventoryError("invalid Git worktree path") diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4afc5d25..e8427755 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1434,6 +1434,53 @@ describe("security scan file inventory", () => { }, ); + test + .skipIf(process.platform === "win32") + .each(["symbolic", "missing", "quoted"])( + "validates %s carriage-return Git worktree normalization", + async (ownership) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + const metadata = join(checkout, ".git", "modules", "nested"); + const original = join(checkout, ".git", "owned\rmetadata"); + const normalized = join(checkout, ".git", "owned metadata"); + const external = join(dirname(checkout), "external-worktree"); + await mkdir(dirname(metadata), { recursive: true }); + await mkdir(original); + await mkdir(external); + if (ownership !== "missing") await symlink(external, normalized); + execFileSync( + "git", + ["init", "-q", "--separate-git-dir", metadata, nested], + { cwd: checkout }, + ); + execFileSync("git", ["-C", nested, "config", "core.worktree", nested]); + await writeFile(join(nested, "visible.ts"), "tracked\n"); + execFileSync("git", ["-C", nested, "add", "visible.ts"]); + const config = join(metadata, "config"); + const configured = `${original}/../../nested`; + await writeFile( + config, + (await readFile(config, "utf8")).replace( + /^([ \t]*worktree[ \t]*=).*$/im, + `$1 ${ownership === "quoted" ? `"${configured}"` : configured}`, + ), + ); + + if (ownership === "quoted") { + expect(await inventory(checkout)).toContain("./nested/visible.ts"); + } else { + await expect(inventory(checkout)).rejects.toThrow( + ownership === "symbolic" + ? "symbolic Git metadata paths are not supported" + : "Git metadata directory does not own selected worktree", + ); + } + }, + ); + test("rejects unrelated external Git common directories", async () => { if (Bun.which("rg") === null) return; From ed0195acfd4be1b0c0bf37580ea9b994560ed771 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 10:53:40 -0700 Subject: [PATCH 090/106] fix(inventory): parse inline Git config section assignments --- .../scripts/generate_in_scope_files.py | 25 ++++++---- .../tests-ts/scan-inventory.test.ts | 47 ++++++++++++++++++- 2 files changed, 61 insertions(+), 11 deletions(-) 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 118343c9..e581c829 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -459,21 +459,26 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: line = raw.lstrip(" \t\r").rstrip("\r") if not line or line.startswith(("#", ";")): continue - if line.startswith("["): + while line.startswith("["): match = re.match( - r'\[[ \t]*(core|extensions|include|includeif)' - r'(?:[ \t\r]+("(?:[^"\\]|\\.)*"))?[ \t]*\]' - r'(?=[ \t\r]*(?:[#;]|$))', + r'\[[ \t]*([a-z][a-z0-9-]*)' + r'(?:([.][^\]\r\n]*)|[ \t\r]+("(?:[^"\\]|\\.)*"))?' + r'[ \t]*\]', line, re.IGNORECASE, ) section = None - if match is not None: - name = match.group(1).casefold() - if (match.group(2) is not None) == (name == "includeif"): - section = name - continue - if section is None: + if match is None: + break + name = match.group(1).casefold() + if ( + name in ("core", "extensions", "include", "includeif") + and match.group(2) is None + and (match.group(3) is not None) == (name == "includeif") + ): + section = name + line = line[match.end() :].lstrip(" \t\r") + if section is None or not line or line.startswith(("#", ";")): continue assignment = re.match( r"([a-z][a-z0-9-]*)(?:([ \t]*=)[ \t\r]*(.*))?", diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index e8427755..eab15aca 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1203,6 +1203,10 @@ describe("security scan file inventory", () => { "hash-comment-override", "semicolon-comment-override", "indented-override", + "inline-section-override", + "inline-carriage-override", + "chained-section-override", + "inline-extension-disabled", "carriage-return-override", "carriage-return-section", "carriage-return-section-comment", @@ -1277,6 +1281,9 @@ describe("security scan file inventory", () => { ownership.startsWith("disabled") || ownership.endsWith("comment-override") || ownership === "indented-override" || + ownership === "inline-section-override" || + ownership === "inline-carriage-override" || + ownership === "chained-section-override" || ownership.startsWith("carriage-return") || ownership === "default-inheritance" || ownership === "unquoted-escape" || @@ -1339,6 +1346,25 @@ describe("security scan file inventory", () => { `$1 # selected owner\n\t\tworktree = ${external}`, ), ); + } else if ( + ownership === "inline-section-override" || + ownership === "inline-carriage-override" || + ownership === "chained-section-override" + ) { + const header = + ownership === "chained-section-override" + ? "[feature][unused.value][core]" + : "[core]"; + const whitespace = ownership === "inline-carriage-override" ? "\r" : ""; + await writeFile( + config, + `${await readFile(config, "utf8")}\n${header}${whitespace}worktree = ${external}\n`, + ); + } else if (ownership === "inline-extension-disabled") { + await writeFile( + config, + `${await readFile(config, "utf8")}\n[extensions]worktreeConfig = false\n`, + ); } else if (ownership === "carriage-return-override") { await writeFile( config, @@ -1418,6 +1444,9 @@ describe("security scan file inventory", () => { ownership === "mixed-case-external" || ownership.endsWith("comment-override") || ownership === "indented-override" || + ownership === "inline-section-override" || + ownership === "inline-carriage-override" || + ownership === "chained-section-override" || ownership.startsWith("carriage-return") || ownership === "default-inheritance" || ownership === "unquoted-escape" || @@ -2208,6 +2237,11 @@ describe("security scan file inventory", () => { ["include", "with BOM"], ["include", "with leading carriage return"], ["include", "with carriage return after header"], + ["include", "with same-line path"], + ["include", "with same-line carriage path"], + ["include", "with chained section headers"], + ['includeIf "gitdir:**"', "with same-line path"], + ['includeIf "gitdir:**"', "with chained section headers"], ['includeIf "gitdir:**"', "with carriage return before condition"], [ 'includeIf "gitdir:**"', @@ -2235,9 +2269,20 @@ describe("security scan file inventory", () => { ? "\r" : " "; const configuredSection = section.replace(" ", whitespace); + const headers = + bom === "with chained section headers" + ? `[feature][unused.value][${configuredSection}]` + : `[${configuredSection}]`; + const assignment = + bom === "with same-line carriage path" + ? "\rpath" + : bom === "with same-line path" || + bom === "with chained section headers" + ? "path" + : "\n\tpath"; await writeFile( config, - `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}[${configuredSection}]${bom === "with carriage return after header" ? "\r# included" : ""}\n\tpath = ${external}\n${await readFile(config, "utf8")}`, + `${bom === "with BOM" ? "\ufeff" : ""}${bom === "with leading carriage return" ? "\r" : ""}${headers}${bom === "with carriage return after header" ? "\r# included" : ""}${assignment} = ${external}\n${await readFile(config, "utf8")}`, ); await expect(inventory(checkout)).rejects.toThrow( From b3404d4f66d459e1dc5aa731374679fd7e51da3a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 11:33:12 -0700 Subject: [PATCH 091/106] fix(inventory): preserve outer indexes across nested roots --- .../scripts/generate_in_scope_files.py | 8 +++-- .../tests-ts/scan-inventory.test.ts | 33 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) 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 e581c829..2462ceb3 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -461,7 +461,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: continue while line.startswith("["): match = re.match( - r'\[[ \t]*([a-z][a-z0-9-]*)' + r'\[[ \t]*([a-z0-9-]*)' r'(?:([.][^\]\r\n]*)|[ \t\r]+("(?:[^"\\]|\\.)*"))?' r'[ \t]*\]', line, @@ -1467,6 +1467,10 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: and exact_descendant(nested, owner) and os.fsencode(nested.relative_to(owner).as_posix()) in indexed_paths ) + tracked_gitlink_identities = { + (directory_identity(owner), directory_identity(nested)) + for owner, nested in tracked_gitlinks + } def tracked_variants( root_identity: tuple[int, int], root: Path, relative: bytes @@ -1541,7 +1545,7 @@ def descend(parent: Path, index: int) -> list[Path]: if not stat.S_ISDIR(metadata.st_mode): continue owner_identity = (metadata.st_dev, metadata.st_ino) - if owner_identity in inspected_roots and owner_identity != root_identity: + if (root_identity, owner_identity) in tracked_gitlink_identities: continue matches.extend(descend(candidate, index + 1)) elif stat.S_ISREG(metadata.st_mode): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index eab15aca..ea75b9a8 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -421,6 +421,35 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./nested/.env"); }); + test.each([".", "nested"])( + "retains outer tracked source inside an embedded checkout for %s", + async (scope) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const nested = join(checkout, "nested"); + await mkdir(nested); + await Promise.all([ + writeFile(join(nested, "outer.ts"), "outer tracked\n"), + writeFile(join(nested, "inner.ts"), "inner tracked\n"), + writeFile(join(nested, "private.ts"), "private\n"), + ]); + execFileSync("git", ["add", "nested/outer.ts"], { cwd: checkout }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await writeFile( + join(nested, ".ignore"), + "outer.ts\ninner.ts\nprivate.ts\n", + ); + execFileSync("git", ["add", "inner.ts"], { cwd: nested }); + + const prefix = scope === "." ? "./nested" : "nested"; + const rows = await inventory(checkout, scope); + expect(rows).toContain(`${prefix}/outer.ts`); + expect(rows).toContain(`${prefix}/inner.ts`); + expect(rows).not.toContain(`${prefix}/private.ts`); + }, + ); + test("recovers an embedded checkout hidden only by its own ignore file", async () => { if (Bun.which("rg") === null) return; @@ -1353,7 +1382,7 @@ describe("security scan file inventory", () => { ) { const header = ownership === "chained-section-override" - ? "[feature][unused.value][core]" + ? '[0][-][.legacy][ "quoted"][feature][unused.value][core]' : "[core]"; const whitespace = ownership === "inline-carriage-override" ? "\r" : ""; await writeFile( @@ -2271,7 +2300,7 @@ describe("security scan file inventory", () => { const configuredSection = section.replace(" ", whitespace); const headers = bom === "with chained section headers" - ? `[feature][unused.value][${configuredSection}]` + ? `[0][-][.legacy][ "quoted"][feature][unused.value][${configuredSection}]` : `[${configuredSection}]`; const assignment = bom === "with same-line carriage path" From e8f0abe9d242353ee3711ed805cd60ab1c511e78 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 11:40:42 -0700 Subject: [PATCH 092/106] fix(inventory): match Unicode aliases by filesystem identity --- .../scripts/generate_in_scope_files.py | 2 +- .../tests-ts/scan-inventory.test.ts | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) 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 2462ceb3..a47ad02d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -1431,7 +1431,7 @@ def visible_nested_root(root: Path) -> bool: directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} def indexed_name_key(value: str) -> bytes: - return os.fsencode(unicodedata.normalize("NFC", value).lower()) + return os.fsencode(unicodedata.normalize("NFC", value).casefold()) selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ea75b9a8..78bcca30 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -326,13 +326,14 @@ describe("security scan file inventory", () => { }); test.each([ - ["SS", "ss", true], - ["Ä", "ä", true], - ["ss", "\u00df", false], - ["caf\u00e9", "cafe\u0301", true], + ["SS", "ss"], + ["Ä", "ä"], + ["Σ", "ς"], + ["ss", "\u00df"], + ["caf\u00e9", "cafe\u0301"], ])( "matches indexed %s against replacement %s using filesystem identity", - async (indexed, replacement, allowAlias) => { + async (indexed, replacement) => { if (Bun.which("rg") === null) return; const checkout = await repository(); @@ -349,14 +350,13 @@ describe("security scan file inventory", () => { "replacement\n", ); await writeFile(join(checkout, ".gitignore"), `${replacement}/\n`); - const expected = - allowAlias && - (await realpath(join(checkout, indexed, "private.ts")).then( - async (path) => - path === - (await realpath(join(checkout, replacement, "private.ts"))), - () => false, - )); + const expected = await realpath( + join(checkout, indexed, "private.ts"), + ).then( + async (path) => + path === (await realpath(join(checkout, replacement, "private.ts"))), + () => false, + ); expect( (await inventory(checkout)).includes(`./${replacement}/private.ts`), From 9d5100af2b77a5349a1eddf794e020bf71a2882f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 11:45:26 -0700 Subject: [PATCH 093/106] fix(inventory): preserve conflicted descendants under gitlinks --- .../scripts/generate_in_scope_files.py | 16 ++-------- .../tests-ts/scan-inventory.test.ts | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 16 deletions(-) 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 a47ad02d..a3d5d34f 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -1467,14 +1467,7 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: and exact_descendant(nested, owner) and os.fsencode(nested.relative_to(owner).as_posix()) in indexed_paths ) - tracked_gitlink_identities = { - (directory_identity(owner), directory_identity(nested)) - for owner, nested in tracked_gitlinks - } - - def tracked_variants( - root_identity: tuple[int, int], root: Path, relative: bytes - ) -> Iterator[Path]: + def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: components = PurePosixPath(os.fsdecode(relative)).parts if not components or any(part in (".", "..") for part in components): return @@ -1544,9 +1537,6 @@ def descend(parent: Path, index: int) -> list[Path]: if index + 1 < len(components): if not stat.S_ISDIR(metadata.st_mode): continue - owner_identity = (metadata.st_dev, metadata.st_ino) - if (root_identity, owner_identity) in tracked_gitlink_identities: - continue matches.extend(descend(candidate, index + 1)) elif stat.S_ISREG(metadata.st_mode): matches.append(candidate) @@ -1570,11 +1560,11 @@ def descend(parent: Path, index: int) -> list[Path]: continue yield candidate - for root_identity, (root, tracked_paths) in cached_by_root.items(): + for root, tracked_paths in cached_by_root.values(): candidates = [ candidate for relative in tracked_paths - for candidate in tracked_variants(root_identity, root, relative) + for candidate in tracked_variants(root, relative) ] outer_visible = ( visible_to_outer_ignores(root, candidates) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 78bcca30..4dbf0551 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -421,9 +421,14 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./nested/.env"); }); - test.each([".", "nested"])( - "retains outer tracked source inside an embedded checkout for %s", - async (scope) => { + test.each([ + ["embedded", "."], + ["embedded", "nested"], + ["conflicted Gitlink", "."], + ["conflicted Gitlink", "nested"], + ])( + "retains outer tracked source inside %s checkout for %s", + async (staging, scope) => { if (Bun.which("rg") === null) return; const checkout = await repository(); @@ -441,6 +446,26 @@ describe("security scan file inventory", () => { "outer.ts\ninner.ts\nprivate.ts\n", ); execFileSync("git", ["add", "inner.ts"], { cwd: nested }); + if (staging === "conflicted Gitlink") { + commit(nested); + const gitlink = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: nested, + encoding: "utf8", + }).trim(); + const outer = execFileSync("git", ["rev-parse", ":nested/outer.ts"], { + cwd: checkout, + encoding: "utf8", + }).trim(); + execFileSync("git", ["update-index", "--index-info"], { + cwd: checkout, + input: [ + `0 ${"0".repeat(40)}\tnested/outer.ts`, + `160000 ${gitlink} 1\tnested`, + `100644 ${outer} 2\tnested/outer.ts`, + "", + ].join("\n"), + }); + } const prefix = scope === "." ? "./nested" : "nested"; const rows = await inventory(checkout, scope); From 8377d81cbc7db818b4aea7168d5586a3b08d1cfa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 11:51:13 -0700 Subject: [PATCH 094/106] fix(inventory): respect active sparse config and Windows aliases --- .../scripts/generate_in_scope_files.py | 26 ++++--- .../tests-ts/scan-inventory.test.ts | 72 +++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) 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 a3d5d34f..1fccbb77 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -433,6 +433,12 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: raise InventoryError("invalid Git worktree path") return bytes(decoded), bytes(normalized) + def config_enabled(value: str | None) -> bool: + if value is None: + return True + normalized = os.fsdecode(decode_config_value(value)[0]).strip(" \t\r").casefold() + return normalized not in ("", "false", "no", "off", "0") + options: dict[tuple[str, str], str | None] = {} config_path = roots[-1] / "config" worktree_config_enabled = False @@ -441,13 +447,9 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: try: for candidate in (config_path, gitdir / "config.worktree"): if candidate != config_path: - extension = options.get(("extensions", "worktreeconfig"), "false") - normalized = ( - "true" - if extension is None - else os.fsdecode(decode_config_value(extension)[0]).strip(" \t\r").casefold() + worktree_config_enabled = config_enabled( + options.get(("extensions", "worktreeconfig"), "false") ) - worktree_config_enabled = normalized not in ("", "false", "no", "off", "0") if not worktree_config_enabled: continue if inspect_metadata(candidate, directory=False) is None: @@ -496,6 +498,7 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: config_includes = True if (section, key) in ( ("core", "worktree"), + ("core", "sparsecheckout"), ("extensions", "worktreeconfig"), ): options[(section, key)] = ( @@ -507,6 +510,9 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: raise InventoryError(f"could not inspect Git metadata: {directory}") from error if config_includes: raise InventoryError("Git config includes are not supported") + sparse_checkout_enabled = config_enabled( + options.get(("core", "sparsecheckout"), "false") + ) configured_worktree = options.get(("core", "worktree")) if gitfile: @@ -564,8 +570,10 @@ def decode_config_value(value: str) -> tuple[bytes, bytes]: if relative in ("HEAD", "index", "config.worktree", "info/sparse-checkout") else roots[-1] ) - if root != effective_root or ( - relative == "config.worktree" and not worktree_config_enabled + if ( + root != effective_root + or relative == "config.worktree" and not worktree_config_enabled + or relative == "info/sparse-checkout" and not sparse_checkout_enabled ): continue if relative == "info/sparse-checkout" and root != roots[-1]: @@ -1431,7 +1439,7 @@ def visible_nested_root(root: Path) -> bool: directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} def indexed_name_key(value: str) -> bytes: - return os.fsencode(unicodedata.normalize("NFC", value).casefold()) + return os.fsencode(unicodedata.normalize("NFC", value).upper().casefold()) selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 4dbf0551..23a7fc82 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -330,6 +330,7 @@ describe("security scan file inventory", () => { ["Ä", "ä"], ["Σ", "ς"], ["ss", "\u00df"], + ["I", "\u0131"], ["caf\u00e9", "cafe\u0301"], ])( "matches indexed %s against replacement %s using filesystem identity", @@ -2364,6 +2365,67 @@ describe("security scan file inventory", () => { expect(await inventory(checkout)).toContain("./visible.ts"); }); + test.each([ + ["unset", "directory", false], + ["false", "symbolic", false], + ["false", "fifo", false], + ["true", "directory", true], + ["worktree-false", "directory", false], + ["worktree-true", "directory", true], + ])( + "inspects %s sparse-checkout %s metadata only when active", + async (setting, kind, active) => { + if ( + Bun.which("rg") === null || + (kind !== "directory" && process.platform === "win32") || + (kind === "fifo" && Bun.which("mkfifo") === null) + ) { + return; + } + + const checkout = await repository(); + if (setting.startsWith("worktree-")) { + execFileSync( + "git", + ["config", "core.sparseCheckout", active ? "false" : "true"], + { cwd: checkout }, + ); + execFileSync("git", ["config", "extensions.worktreeConfig", "true"], { + cwd: checkout, + }); + execFileSync( + "git", + ["config", "--worktree", "core.sparseCheckout", String(active)], + { cwd: checkout }, + ); + } else if (setting !== "unset") { + execFileSync("git", ["config", "core.sparseCheckout", setting], { + cwd: checkout, + }); + } + + const metadata = join(checkout, ".git", "info", "sparse-checkout"); + if (kind === "symbolic") { + const external = join(dirname(checkout), "unused-sparse-checkout"); + await writeFile(external, "external\n"); + await symlink(external, metadata); + } else if (kind === "fifo") { + execFileSync("mkfifo", [metadata]); + } else { + await mkdir(metadata); + } + await writeFile(join(checkout, "visible.ts"), "visible\n"); + + if (active) { + await expect(inventory(checkout)).rejects.toThrow( + "non-regular Git metadata files are not supported", + ); + } else { + expect(await inventory(checkout)).toContain("./visible.ts"); + } + }, + ); + test .skipIf(process.platform === "win32") .each(["config", "info/sparse-checkout"])( @@ -2372,6 +2434,11 @@ describe("security scan file inventory", () => { if (Bun.which("rg") === null || Bun.which("mkfifo") === null) return; const checkout = await repository(); + if (relative === "info/sparse-checkout") { + execFileSync("git", ["config", "core.sparseCheckout", "true"], { + cwd: checkout, + }); + } const metadata = join(checkout, ".git", relative); await rm(metadata, { force: true }); execFileSync("mkfifo", [metadata]); @@ -2410,6 +2477,11 @@ describe("security scan file inventory", () => { if (Bun.which("rg") === null) return; const checkout = await repository(); + if (relative === "info/sparse-checkout") { + execFileSync("git", ["config", "core.sparseCheckout", "true"], { + cwd: checkout, + }); + } const external = await repository(); await writeFile(join(external, "source.ts"), "tracked\n"); execFileSync("git", ["add", "source.ts"], { cwd: external }); From 59e289d1a07d19e47cda894feecb46b897d968c2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 11:59:46 -0700 Subject: [PATCH 095/106] fix(inventory): honor Git numeric boolean spellings --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 4 +++- sdk/typescript/tests-ts/scan-inventory.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 1fccbb77..ccc55e64 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -437,7 +437,9 @@ def config_enabled(value: str | None) -> bool: if value is None: return True normalized = os.fsdecode(decode_config_value(value)[0]).strip(" \t\r").casefold() - return normalized not in ("", "false", "no", "off", "0") + return normalized not in ("", "false", "no", "off") and re.fullmatch( + r"[+-]?(?:0+|0x0+)[kmg]?", normalized + ) is None options: dict[tuple[str, str], str | None] = {} config_path = roots[-1] / "config" diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 23a7fc82..114e51bb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2369,6 +2369,12 @@ describe("security scan file inventory", () => { ["unset", "directory", false], ["false", "symbolic", false], ["false", "fifo", false], + ["00", "directory", false], + ["+0", "symbolic", false], + ["0k", "fifo", false], + ["0x0", "directory", false], + ["+0x00g", "directory", false], + ["1k", "directory", true], ["true", "directory", true], ["worktree-false", "directory", false], ["worktree-true", "directory", true], From f36849d5d54a17d88a3400cd9fab32325fdba9fc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:05:09 -0700 Subject: [PATCH 096/106] fix(inventory): reject symbolic metadata aliases without following --- .../scripts/generate_in_scope_files.py | 8 +++- .../tests-ts/scan-inventory.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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 ccc55e64..46b1e1ae 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -36,7 +36,13 @@ def git_metadata_path(parent: Path, name: str) -> bool: if name.casefold().rstrip(". ") != ".git": return False try: - return (parent / name).samefile(parent / ".git") + candidate = (parent / name).stat(follow_symlinks=False) + if symbolic_metadata(candidate): + raise InventoryError("symbolic Git metadata paths are not supported") + metadata = (parent / ".git").stat(follow_symlinks=False) + if symbolic_metadata(metadata): + raise InventoryError("symbolic Git metadata paths are not supported") + return (candidate.st_dev, candidate.st_ino) == (metadata.st_dev, metadata.st_ino) except OSError: return False diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 114e51bb..6f2632e2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1191,6 +1191,44 @@ describe("security scan file inventory", () => { }, ); + test.each([".GIT", ".GIT."])( + "rejects symbolic %s metadata before resolving its filesystem alias", + async (alias) => { + const checkout = await repository(); + const nested = join(checkout, "visible"); + const external = join(dirname(checkout), "external-metadata"); + await mkdir(nested); + await mkdir(external); + await symlink( + external, + join(nested, alias), + process.platform === "win32" ? "junction" : "dir", + ); + + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + "original = Path.samefile", + "def guarded(self, other):", + " if self.parent.name == 'visible' and self.name.casefold().rstrip('. ') == '.git':", + " raise RuntimeError('followed symbolic Git metadata alias')", + " return original(self, other)", + "Path.samefile = guarded", + ].join("\n"), + ); + + await expect( + inventory(checkout, ".", { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).rejects.toThrow("symbolic Git metadata paths are not supported"); + }, + ); + test.each(["missing", "mismatched"])( "rejects an external gitdir with a %s worktree backpointer", async (ownership) => { From 661c6254c192bb75dede1757166110da276fb1ec Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:16:02 -0700 Subject: [PATCH 097/106] fix(inventory): classify Windows Unicode Git metadata aliases --- .../_bundled_plugin/scripts/generate_in_scope_files.py | 2 +- sdk/typescript/tests-ts/scan-inventory.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 46b1e1ae..74a1961b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -33,7 +33,7 @@ def symbolic_metadata(metadata: os.stat_result) -> bool: def git_metadata_path(parent: Path, name: str) -> bool: if name == ".git": return True - if name.casefold().rstrip(". ") != ".git": + if name.upper().casefold().rstrip(". ") != ".git": return False try: candidate = (parent / name).stat(follow_symlinks=False) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6f2632e2..3befbd6b 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1191,9 +1191,11 @@ describe("security scan file inventory", () => { }, ); - test.each([".GIT", ".GIT."])( + test.each([".GIT", ".GIT.", ".g\u0131t", ".g\u0131t."])( "rejects symbolic %s metadata before resolving its filesystem alias", async (alias) => { + if (process.platform === "win32" && alias.endsWith(".")) return; + const checkout = await repository(); const nested = join(checkout, "visible"); const external = join(dirname(checkout), "external-metadata"); @@ -1213,7 +1215,7 @@ describe("security scan file inventory", () => { "from pathlib import Path", "original = Path.samefile", "def guarded(self, other):", - " if self.parent.name == 'visible' and self.name.casefold().rstrip('. ') == '.git':", + " if self.parent.name == 'visible' and self.name.upper().casefold().rstrip('. ') == '.git':", " raise RuntimeError('followed symbolic Git metadata alias')", " return original(self, other)", "Path.samefile = guarded", From b70403761bf3628b283263eb813a457032fa2071 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:26:52 -0700 Subject: [PATCH 098/106] fix(inventory): unify Windows-safe filesystem alias checks --- .../scripts/generate_in_scope_files.py | 25 ++++--- .../tests-ts/scan-inventory.test.ts | 73 +++++++++++++++++++ 2 files changed, 86 insertions(+), 12 deletions(-) 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 74a1961b..47c61da9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -30,10 +30,14 @@ def symbolic_metadata(metadata: os.stat_result) -> bool: ) +def filesystem_name_key(value: str) -> str: + return unicodedata.normalize("NFC", value).upper().casefold() + + def git_metadata_path(parent: Path, name: str) -> bool: if name == ".git": return True - if name.upper().casefold().rstrip(". ") != ".git": + if filesystem_name_key(name).rstrip(". ") != ".git": return False try: candidate = (parent / name).stat(follow_symlinks=False) @@ -235,7 +239,7 @@ def inspect_object_store(objects: Path) -> None: return entries = objects.iterdir() for entry in entries: - canonical = entry.name.casefold() + canonical = filesystem_name_key(entry.name) if canonical not in ("info", "pack") and not re.fullmatch( r"[0-9a-f]{2}", canonical ): @@ -245,7 +249,7 @@ def inspect_object_store(objects: Path) -> None: inspect_metadata(entry, directory=True) if canonical != "info": for member in entry.iterdir(): - member_canonical = member.name.casefold() + member_canonical = filesystem_name_key(member.name) if canonical == "pack": if member_canonical == "multi-pack-index.d": if member.name != member_canonical and not aliases_canonical_path( @@ -254,7 +258,7 @@ def inspect_object_store(objects: Path) -> None: continue inspect_metadata(member, directory=True) for layer in member.iterdir(): - layer_canonical = layer.name.casefold() + layer_canonical = filesystem_name_key(layer.name) if layer_canonical != "multi-pack-index-chain" and not re.fullmatch( r"multi-pack-index-[0-9a-f]{40}(?:[0-9a-f]{24})?\.(?:midx|bitmap|rev)", layer_canonical, @@ -274,7 +278,7 @@ def inspect_object_store(objects: Path) -> None: continue else: stem, _, suffix = member.name.rpartition(".") - expected = f"{stem}.{suffix.casefold()}" + expected = f"{stem}.{filesystem_name_key(suffix)}" else: if not re.fullmatch( r"(?:[0-9a-f]{38}|[0-9a-f]{62})", member_canonical @@ -718,7 +722,7 @@ def config_enabled(value: str | None) -> bool: continue try: for shared_index in root.iterdir(): - canonical = shared_index.name.casefold() + canonical = filesystem_name_key(shared_index.name) if not re.fullmatch( r"sharedindex\.(?:[0-9a-f]{40}|[0-9a-f]{64})", canonical ): @@ -886,13 +890,10 @@ def visible_to_outer_ignores( batches: list[tuple[dict[str, str], set[bytes]]] = [] - def probe_name_key(value: str) -> str: - return unicodedata.normalize("NFC", value).casefold() - for relative in requested: parts = PurePosixPath(os.fsdecode(relative)).parts prefixes = { - probe_name_key("/".join(parts[: index + 1])): "/".join(parts[: index + 1]) + filesystem_name_key("/".join(parts[: index + 1])): "/".join(parts[: index + 1]) for index in range(len(parts)) } for names, batch in batches: @@ -917,7 +918,7 @@ def collides_with_candidates(relative: tuple[str, ...]) -> bool: zip(PurePosixPath(os.fsdecode(candidate)).parts, relative) ) if all( - probe_name_key(actual) == probe_name_key(synthetic) + filesystem_name_key(actual) == filesystem_name_key(synthetic) for actual, synthetic in pairs ) and any(actual != synthetic for actual, synthetic in pairs): return True @@ -1447,7 +1448,7 @@ def visible_nested_root(root: Path) -> bool: directory_entries: dict[tuple[int, int], dict[bytes, list[Path]]] = {} def indexed_name_key(value: str) -> bytes: - return os.fsencode(unicodedata.normalize("NFC", value).upper().casefold()) + return os.fsencode(filesystem_name_key(value)) selected_parts = tuple( os.fsencode(part) for part in selected.relative_to(repository).parts diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 3befbd6b..ff43d988 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2284,6 +2284,79 @@ describe("security scan file inventory", () => { }, ); + test.each([ + "shared-index", + "multi-pack-index", + "pack-index-suffix", + "incremental-index-directory", + "incremental-index-chain", + "incremental-index-bitmap", + ])("rejects Windows-compatible %s metadata aliases", async (kind) => { + const checkout = await repository(); + const gitdir = join(checkout, ".git"); + let directory = join(gitdir, "objects", "pack"); + let canonical = "multi-pack-index"; + + if (kind === "shared-index") { + await writeFile(join(checkout, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + execFileSync("git", ["update-index", "--split-index"], { + cwd: checkout, + }); + const shared = (await readdir(gitdir)).find((name) => + name.startsWith("sharedindex."), + ); + if (shared === undefined) throw new Error("Expected a split Git index."); + directory = gitdir; + canonical = shared; + await rm(join(directory, canonical)); + } else if (kind === "pack-index-suffix") { + canonical = `pack-${"a".repeat(40)}.idx`; + } else if (kind === "incremental-index-directory") { + canonical = "multi-pack-index.d"; + } else if (kind.startsWith("incremental-index-")) { + directory = join(directory, "multi-pack-index.d"); + await mkdir(directory); + canonical = + kind === "incremental-index-chain" + ? "multi-pack-index-chain" + : `multi-pack-index-${"a".repeat(40)}.bitmap`; + } + + const alias = canonical.replace("i", "\u0131"); + const external = join(dirname(checkout), "external-metadata"); + await mkdir(external); + await symlink( + external, + join(directory, alias), + process.platform === "win32" ? "junction" : "dir", + ); + + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + `canonical = Path(${JSON.stringify(join(directory, canonical))})`, + `alias = Path(${JSON.stringify(join(directory, alias))})`, + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self == canonical and kwargs.get('follow_symlinks') is False:", + " return original(alias, *args, **kwargs)", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + + await expect( + inventory(checkout, ".", { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).rejects.toThrow("symbolic Git metadata paths are not supported"); + }); + test.skipIf(process.platform === "win32").each(["lowercase", "uppercase"])( "rejects %s split-index backing files that leave the checkout", async (casing) => { From bd660bfb0cc12049ec71d2400d0ca3f49c8abacf Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:33:39 -0700 Subject: [PATCH 099/106] fix(inventory): validate common worktree owners without following --- .../scripts/generate_in_scope_files.py | 7 ++- .../tests-ts/scan-inventory.test.ts | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) 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 47c61da9..cad3681f 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -363,8 +363,11 @@ def inspect_object_store(objects: Path) -> None: if inspected_common is None: raise InventoryError("Git common directory does not own selected worktree") common = inspected_common - owner = common / "worktrees" / gitdir.name - if not same_filesystem_path(owner, gitdir): + owner = inspect_metadata_path( + common / "worktrees" / gitdir.name, + directory_path=True, + ) + if owner is None or not same_filesystem_path(owner, gitdir): raise InventoryError("Git common directory does not own selected worktree") roots.append(common) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index ff43d988..6c2874eb 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -1191,6 +1191,62 @@ describe("security scan file inventory", () => { }, ); + test.each(["worktrees", "owner"])( + "rejects symbolic common %s metadata before following worktree ownership", + async (kind) => { + const checkout = await repository(); + await writeFile(join(checkout, "tracked.ts"), "tracked\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + commit(checkout); + const linked = join(dirname(checkout), "linked-worktree"); + execFileSync("git", ["worktree", "add", "--detach", linked, "HEAD"], { + cwd: checkout, + stdio: "ignore", + }); + const gitdir = (await readFile(join(linked, ".git"), "utf8")) + .replace(/^gitdir: /, "") + .trim(); + const common = join(dirname(checkout), "common-metadata"); + const external = join(dirname(checkout), "external-worktrees"); + await mkdir(common); + await mkdir(external); + if (kind === "owner") await mkdir(join(common, "worktrees")); + const symbolic = + kind === "worktrees" + ? join(common, "worktrees") + : join(common, "worktrees", basename(gitdir)); + await symlink( + external, + symbolic, + process.platform === "win32" ? "junction" : "dir", + ); + await writeFile(join(gitdir, "commondir"), `${common}\n`); + + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + `owner = Path(${JSON.stringify(join(common, "worktrees", basename(gitdir)))})`, + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self == owner and kwargs.get('follow_symlinks', True):", + " raise RuntimeError('followed unvalidated Git worktree owner')", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + + await expect( + inventory(linked, ".", { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).rejects.toThrow("symbolic Git metadata paths are not supported"); + }, + ); + test.each([".GIT", ".GIT.", ".g\u0131t", ".g\u0131t."])( "rejects symbolic %s metadata before resolving its filesystem alias", async (alias) => { From b6f26ca2fe81715a2a771e7ded53938e83d9a808 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:37:31 -0700 Subject: [PATCH 100/106] fix(inventory): validate alternate prefixes before identity probes --- .../scripts/generate_in_scope_files.py | 13 ++-- .../tests-ts/scan-inventory.test.ts | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) 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 cad3681f..fa1eaaee 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -665,14 +665,13 @@ def config_enabled(value: str | None) -> bool: for candidate in (repository, *roots): if len(alternate.parts) < len(candidate.parts): continue - candidate_anchor = Path(*alternate.parts[: len(candidate.parts)]) - metadata = inspect_metadata(candidate_anchor, directory=True) - if metadata is None: + candidate_anchor = inspect_metadata_path( + Path(*alternate.parts[: len(candidate.parts)]), + directory_path=True, + ) + if candidate_anchor is None: continue - if ( - metadata.st_dev, - metadata.st_ino, - ) != directory_identity(candidate): + if directory_identity(candidate_anchor) != directory_identity(candidate): continue owner = candidate anchor = candidate_anchor diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6c2874eb..6f179f25 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2031,6 +2031,72 @@ describe("security scan file inventory", () => { await expect(readFile(trace, "utf8")).rejects.toThrow(); }); + test.each(["primary", "transitive"])( + "rejects symbolic %s Git alternate prefixes before probing ownership", + async (kind) => { + const parent = await repository(false); + const checkout = join(parent, "selected", "nested"); + await mkdir(checkout, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: checkout }); + const outside = join(dirname(parent), "outside"); + await mkdir(join(outside, "target"), { recursive: true }); + const hop = join(parent, "hop"); + await symlink( + outside, + hop, + process.platform === "win32" ? "junction" : "dir", + ); + const separator = process.platform === "win32" ? "\\" : "/"; + const alternate = [ + join(hop, "target"), + "..", + "selected", + "nested", + ".git", + "extra-objects", + ].join(separator); + const alternates = join( + checkout, + ".git", + "objects", + "info", + "alternates", + ); + if (kind === "primary") { + await writeFile(alternates, `${alternate}\n`); + } else { + const first = join(checkout, ".git", "first-objects"); + await mkdir(join(first, "info"), { recursive: true }); + await mkdir(join(first, "pack")); + await writeFile(alternates, `${first}\n`); + await writeFile(join(first, "info", "alternates"), `${alternate}\n`); + } + + const instrumentation = join(dirname(parent), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + `unsafe = Path(${JSON.stringify(join(hop, "target"))})`, + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self == unsafe:", + " raise RuntimeError('probed unvalidated Git alternate prefix')", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + + await expect( + inventory(checkout, ".", { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).rejects.toThrow("symbolic Git metadata paths are not supported"); + }, + ); + test.skipIf(process.platform === "win32").each(["primary", "transitive"])( "rejects symbolic %s Git object-alternate hops before parent traversal", async (kind) => { From 9bdabb09aa4eff7379adfa65ba3b88e88fec9e77 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 12:47:44 -0700 Subject: [PATCH 101/106] fix(inventory): skip disabled Git replacement metadata --- .../scripts/generate_in_scope_files.py | 13 -------- .../tests-ts/scan-inventory.test.ts | 31 +++++++++++++------ 2 files changed, 21 insertions(+), 23 deletions(-) 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 fa1eaaee..9315a62a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -569,7 +569,6 @@ def config_enabled(value: str | None) -> bool: "packed-refs", "refs", "refs/heads", - "refs/replace", "refs/tags", "config", "config.worktree", @@ -599,7 +598,6 @@ def config_enabled(value: str | None) -> bool: directory=relative in ( "refs", "refs/heads", - "refs/replace", "refs/tags", "info", "objects", @@ -608,17 +606,6 @@ def config_enabled(value: str | None) -> bool: ) if metadata is not None and relative == "objects": inspect_object_store(path) - if metadata is not None and relative == "refs/replace": - try: - for reference in path.iterdir(): - if re.fullmatch( - r"[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?", reference.name - ): - inspect_metadata(reference, directory=False) - except OSError as error: - raise InventoryError( - f"could not inspect Git metadata: {directory}" - ) from error if metadata is not None and relative == "objects/info/alternates": try: contents = path.read_bytes() diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 6f179f25..85051481 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2713,7 +2713,6 @@ describe("security scan file inventory", () => { "packed-refs", "refs", "refs/heads", - "refs/replace", ])("rejects a symbolic Git metadata %s", async (relative) => { if (Bun.which("rg") === null) return; @@ -2741,22 +2740,34 @@ describe("security scan file inventory", () => { ); }); - test.skipIf(process.platform === "win32")( - "rejects symbolic Git object replacement refs", - async () => { - if (Bun.which("rg") === null) return; + test.each(["file", "symbolic", "fifo", "reference"])( + "ignores inactive Git replacement %s metadata", + async (kind) => { + if ( + Bun.which("rg") === null || + (kind !== "file" && process.platform === "win32") || + (kind === "fifo" && Bun.which("mkfifo") === null) + ) { + return; + } const checkout = await repository(); const replacement = join(checkout, ".git", "refs", "replace"); const external = join(dirname(checkout), "external-replacement"); - await mkdir(replacement); await writeFile(external, `${"0".repeat(40)}\n`); - await symlink(external, join(replacement, "a".repeat(40))); + if (kind === "file") { + await writeFile(replacement, "inactive\n"); + } else if (kind === "symbolic") { + await symlink(external, replacement); + } else if (kind === "fifo") { + execFileSync("mkfifo", [replacement]); + } else { + await mkdir(replacement); + await symlink(external, join(replacement, "a".repeat(40))); + } await writeFile(join(checkout, "visible.ts"), "visible\n"); - await expect(inventory(checkout)).rejects.toThrow( - "symbolic Git metadata paths are not supported", - ); + expect(await inventory(checkout)).toContain("./visible.ts"); }, ); From 02642b0b4e7b3edf737dbc2e36be99c364dc388f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 13:00:02 -0700 Subject: [PATCH 102/106] fix(inventory): validate only active split-index backing files --- .../scripts/generate_in_scope_files.py | 68 ++++++++++++++--- .../tests-ts/scan-inventory.test.ts | 74 ++++++++++++++++++- 2 files changed, 129 insertions(+), 13 deletions(-) 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 9315a62a..5268da8a 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -6,6 +6,7 @@ import argparse import codecs import io +import mmap import os import re import stat @@ -34,6 +35,52 @@ def filesystem_name_key(value: str) -> str: return unicodedata.normalize("NFC", value).upper().casefold() +def split_index_backing(index: Path, hash_size: int) -> str | None: + with index.open("rb") as handle: + if os.fstat(handle.fileno()).st_size == 0: + return None + with mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as contents: + if len(contents) < 12 + hash_size or contents[:4] != b"DIRC": + return None + version = int.from_bytes(contents[4:8], "big") + if version not in (2, 3, 4): + return None + count = int.from_bytes(contents[8:12], "big") + position = 12 + limit = len(contents) - hash_size + for _ in range(count): + entry = position + position += 40 + hash_size + if position + 2 > limit: + return None + flags = int.from_bytes(contents[position : position + 2], "big") + position += 2 + (2 if flags & 0x4000 else 0) + if version == 4: + while position < limit: + byte = contents[position] + position += 1 + if not byte & 0x80: + break + else: + return None + end = contents.find(b"\0", position, limit) + if end < 0: + return None + position = end + 1 if version == 4 else entry + ((end - entry + 8) & ~7) + while position + 8 <= limit: + signature = contents[position : position + 4] + length = int.from_bytes(contents[position + 4 : position + 8], "big") + position += 8 + if position + length > limit: + return None + if signature == b"link": + if length < hash_size: + return None + return contents[position : position + hash_size].hex() + position += length + return None + + def git_metadata_path(parent: Path, name: str) -> bool: if name == ".git": return True @@ -514,6 +561,7 @@ def config_enabled(value: str | None) -> bool: if (section, key) in ( ("core", "worktree"), ("core", "sparsecheckout"), + ("extensions", "objectformat"), ("extensions", "worktreeconfig"), ): options[(section, key)] = ( @@ -710,17 +758,15 @@ def config_enabled(value: str | None) -> bool: if root != gitdir: continue try: - for shared_index in root.iterdir(): - canonical = filesystem_name_key(shared_index.name) - if not re.fullmatch( - r"sharedindex\.(?:[0-9a-f]{40}|[0-9a-f]{64})", canonical - ): - continue - if shared_index.name != canonical and not aliases_canonical_path( - shared_index, canonical - ): - continue - inspect_metadata(shared_index, directory=False) + configured_format = options.get(("extensions", "objectformat"), "sha1") + object_format = ( + "sha1" + if configured_format is None + else os.fsdecode(decode_config_value(configured_format)[0]).strip(" \t\r").casefold() + ) + backing = split_index_backing(root / "index", 32 if object_format == "sha256" else 20) + if backing is not None: + inspect_metadata(root / f"sharedindex.{backing}", directory=False) except FileNotFoundError: continue except OSError as error: diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 85051481..35b6ee0c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2408,20 +2408,33 @@ describe("security scan file inventory", () => { test.each([ "shared-index", + "shared-index-v4", + "shared-index-sha256", "multi-pack-index", "pack-index-suffix", "incremental-index-directory", "incremental-index-chain", "incremental-index-bitmap", ])("rejects Windows-compatible %s metadata aliases", async (kind) => { - const checkout = await repository(); + const sha256 = kind === "shared-index-sha256"; + const checkout = await repository(!sha256); + if (sha256) { + execFileSync("git", ["init", "-q", "--object-format=sha256"], { + cwd: checkout, + }); + } const gitdir = join(checkout, ".git"); let directory = join(gitdir, "objects", "pack"); let canonical = "multi-pack-index"; - if (kind === "shared-index") { + if (kind.startsWith("shared-index")) { await writeFile(join(checkout, "tracked.ts"), "tracked\n"); execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + if (kind === "shared-index-v4") { + execFileSync("git", ["update-index", "--index-version=4"], { + cwd: checkout, + }); + } execFileSync("git", ["update-index", "--split-index"], { cwd: checkout, }); @@ -2479,6 +2492,63 @@ describe("security scan file inventory", () => { ).rejects.toThrow("symbolic Git metadata paths are not supported"); }); + test.each([ + ["missing", "symbolic"], + ["normal", "symbolic"], + ["normal", "fifo"], + ["normal", "directory"], + ["split", "symbolic"], + ["split-v4", "symbolic"], + ["split-sha256", "symbolic"], + ])("ignores stale %s split-index %s metadata", async (mode, kind) => { + if ( + Bun.which("rg") === null || + process.platform === "win32" || + (kind === "fifo" && Bun.which("mkfifo") === null) + ) { + return; + } + + const sha256 = mode === "split-sha256"; + const checkout = await repository(!sha256); + if (sha256) { + execFileSync("git", ["init", "-q", "--object-format=sha256"], { + cwd: checkout, + }); + } + await writeFile(join(checkout, "tracked.ts"), "tracked\n"); + if (mode !== "missing") { + execFileSync("git", ["add", "tracked.ts"], { cwd: checkout }); + } + if (mode.startsWith("split")) { + if (mode === "split-v4") { + execFileSync("git", ["update-index", "--index-version=4"], { + cwd: checkout, + }); + } + execFileSync("git", ["update-index", "--split-index"], { + cwd: checkout, + }); + } + + const stale = join( + checkout, + ".git", + `sharedindex.${"f".repeat(sha256 ? 64 : 40)}`, + ); + if (kind === "directory") { + await mkdir(stale); + } else if (kind === "fifo") { + execFileSync("mkfifo", [stale]); + } else { + const external = join(dirname(checkout), "unused-shared-index"); + await writeFile(external, "inactive\n"); + await symlink(external, stale); + } + + expect(await inventory(checkout)).toContain("./tracked.ts"); + }); + test.skipIf(process.platform === "win32").each(["lowercase", "uppercase"])( "rejects %s split-index backing files that leave the checkout", async (casing) => { From 99babe704be2c6e47818228fcc82e8eafcd89165 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 13:09:31 -0700 Subject: [PATCH 103/106] fix(inventory): derive index hash format from common config --- .../scripts/generate_in_scope_files.py | 2 ++ sdk/typescript/tests-ts/scan-inventory.test.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) 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 5268da8a..4cc07f7b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -564,6 +564,8 @@ def config_enabled(value: str | None) -> bool: ("extensions", "objectformat"), ("extensions", "worktreeconfig"), ): + if (section, key) == ("extensions", "objectformat") and candidate != config_path: + continue options[(section, key)] = ( None if assignment.group(2) is None diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 35b6ee0c..95245b0c 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -2410,13 +2410,15 @@ describe("security scan file inventory", () => { "shared-index", "shared-index-v4", "shared-index-sha256", + "shared-index-worktree-sha256", + "shared-index-sha256-worktree-sha1", "multi-pack-index", "pack-index-suffix", "incremental-index-directory", "incremental-index-chain", "incremental-index-bitmap", ])("rejects Windows-compatible %s metadata aliases", async (kind) => { - const sha256 = kind === "shared-index-sha256"; + const sha256 = kind.startsWith("shared-index-sha256"); const checkout = await repository(!sha256); if (sha256) { execFileSync("git", ["init", "-q", "--object-format=sha256"], { @@ -2438,6 +2440,15 @@ describe("security scan file inventory", () => { execFileSync("git", ["update-index", "--split-index"], { cwd: checkout, }); + if (kind.includes("-worktree-")) { + execFileSync("git", ["config", "extensions.worktreeConfig", "true"], { + cwd: checkout, + }); + await writeFile( + join(gitdir, "config.worktree"), + `[extensions]\n\tobjectFormat = ${sha256 ? "sha1" : "sha256"}\n`, + ); + } const shared = (await readdir(gitdir)).find((name) => name.startsWith("sharedindex."), ); From 0579357829e4142b39e3ccb6a9cf3f19ac5d2d55 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 13:15:50 -0700 Subject: [PATCH 104/106] fix(inventory): normalize Windows metadata suffix aliases --- .../scripts/generate_in_scope_files.py | 4 +- .../tests-ts/scan-inventory.test.ts | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) 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 4cc07f7b..61bff9a0 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -32,7 +32,7 @@ def symbolic_metadata(metadata: os.stat_result) -> bool: def filesystem_name_key(value: str) -> str: - return unicodedata.normalize("NFC", value).upper().casefold() + return unicodedata.normalize("NFC", value).upper().casefold().rstrip(". ") def split_index_backing(index: Path, hash_size: int) -> str | None: @@ -84,7 +84,7 @@ def split_index_backing(index: Path, hash_size: int) -> str | None: def git_metadata_path(parent: Path, name: str) -> bool: if name == ".git": return True - if filesystem_name_key(name).rstrip(". ") != ".git": + if filesystem_name_key(name) != ".git": return False try: candidate = (parent / name).stat(follow_symlinks=False) diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index 95245b0c..e0bc2b8d 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -149,6 +149,25 @@ describe("security scan file inventory", () => { }, ); + test.skipIf(process.platform === "win32")( + "preserves distinct POSIX trailing-dot and trailing-space source paths", + async () => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const names = ["source.ts", "source.ts.", "source.ts "]; + await Promise.all( + names.map((name) => writeFile(join(checkout, name), "tracked\n")), + ); + execFileSync("git", ["add", ...names], { cwd: checkout }); + + const entries = await inventory(checkout); + for (const name of names) { + expect(entries).toContain(`./${name}`); + } + }, + ); + test.each([".ignore", ".rgignore"])( "keeps ordinary files re-included by higher-precedence %s rules", async (override) => { @@ -2417,6 +2436,19 @@ describe("security scan file inventory", () => { "incremental-index-directory", "incremental-index-chain", "incremental-index-bitmap", + ...(process.platform === "win32" + ? [] + : [ + "object-store-trailing-dot", + "object-store-trailing-space", + "object-prefix-trailing-dot", + "pack-index-trailing-dot", + "pack-index-trailing-space", + "multi-pack-index-trailing-dot", + "incremental-index-directory-trailing-space", + "incremental-index-chain-trailing-dot", + "incremental-index-bitmap-trailing-space", + ]), ])("rejects Windows-compatible %s metadata aliases", async (kind) => { const sha256 = kind.startsWith("shared-index-sha256"); const checkout = await repository(!sha256); @@ -2456,20 +2488,30 @@ describe("security scan file inventory", () => { directory = gitdir; canonical = shared; await rm(join(directory, canonical)); - } else if (kind === "pack-index-suffix") { + } else if (kind.startsWith("object-store")) { + directory = join(gitdir, "objects"); + canonical = "pack"; + await rm(join(directory, canonical), { recursive: true }); + } else if (kind.startsWith("object-prefix")) { + directory = join(gitdir, "objects"); + canonical = "aa"; + } else if (kind.startsWith("pack-index")) { canonical = `pack-${"a".repeat(40)}.idx`; - } else if (kind === "incremental-index-directory") { + } else if (kind.startsWith("incremental-index-directory")) { canonical = "multi-pack-index.d"; } else if (kind.startsWith("incremental-index-")) { directory = join(directory, "multi-pack-index.d"); await mkdir(directory); - canonical = - kind === "incremental-index-chain" - ? "multi-pack-index-chain" - : `multi-pack-index-${"a".repeat(40)}.bitmap`; + canonical = kind.startsWith("incremental-index-chain") + ? "multi-pack-index-chain" + : `multi-pack-index-${"a".repeat(40)}.bitmap`; } - const alias = canonical.replace("i", "\u0131"); + const alias = kind.endsWith("-trailing-dot") + ? `${canonical}.` + : kind.endsWith("-trailing-space") + ? `${canonical} ` + : canonical.replace("i", "\u0131"); const external = join(dirname(checkout), "external-metadata"); await mkdir(external); await symlink( From 985c7844594aa438a7e755b321a84e2798516841 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 13:20:27 -0700 Subject: [PATCH 105/106] fix(inventory): restore tracked Windows short-name aliases --- .../scripts/generate_in_scope_files.py | 20 +++++++-- .../tests-ts/scan-inventory.test.ts | 42 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) 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 61bff9a0..66416302 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -1537,10 +1537,6 @@ def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: continue if index < len(root_parts): return - indexed_name = os.fsdecode(indexed) - requested_name = os.fsdecode(requested) - if indexed_name_key(indexed_name) != indexed_name_key(requested_name): - return def descend(parent: Path, index: int) -> list[Path]: try: @@ -1563,6 +1559,22 @@ def descend(parent: Path, index: int) -> list[Path]: variants = directory_entries[parent_identity].get( indexed_name_key(component), [] ) + if not variants: + try: + expected = (parent / component).stat(follow_symlinks=False) + except OSError: + return [] + if symbolic_metadata(expected): + return [] + variants = [ + candidate + for group in directory_entries[parent_identity].values() + for candidate in group + ] + selected_index = len(root_parts) + index + if selected_index < len(selected_parts): + requested = os.fsdecode(selected_parts[selected_index]) + variants = [candidate for candidate in variants if candidate.name == requested] exact = [candidate for candidate in variants if candidate.name == component] alternatives = [ candidate diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index e0bc2b8d..d3bdd75f 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -396,6 +396,48 @@ describe("security scan file inventory", () => { }, ); + test.each([".", "LongDirectory", "LongDirectory/private.ts"])( + "restores tracked 8.3 aliases for %s scans using no-follow identity", + async (scope) => { + if (Bun.which("rg") === null) return; + + const checkout = await repository(); + const indexed = join(checkout, "LONGDI~1"); + const materialized = join(checkout, "LongDirectory"); + await mkdir(indexed); + await writeFile(join(indexed, "private.ts"), "tracked\n"); + execFileSync("git", ["add", "LONGDI~1/private.ts"], { cwd: checkout }); + await rm(indexed, { recursive: true }); + await mkdir(materialized); + await writeFile(join(materialized, "private.ts"), "tracked\n"); + await writeFile(join(checkout, ".gitignore"), "LongDirectory/\n"); + + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + `indexed = Path(${JSON.stringify(indexed)})`, + `materialized = Path(${JSON.stringify(materialized)})`, + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self == indexed and kwargs.get('follow_symlinks') is False:", + " return original(materialized, *args, **kwargs)", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + + expect( + await inventory(checkout, scope, { + ...process.env, + PYTHONPATH: instrumentation, + }), + ).toContain(`${scope === "." ? "./" : ""}LongDirectory/private.ts`); + }, + ); + test("keeps an explicitly selected ignored file without widening its directory", async () => { if (Bun.which("rg") === null) return; From bed4a1b8d6dcba508495bb4087c49921992efa68 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 11 Aug 2026 13:33:44 -0700 Subject: [PATCH 106/106] fix(inventory): match exact short-name files and Gitlinks --- .../scripts/generate_in_scope_files.py | 38 +++++++- .../tests-ts/scan-inventory.test.ts | 96 +++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) 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 66416302..627c305c 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -1497,6 +1497,32 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: parent_parts = parent.relative_to(repository).parts return candidate_parts[: len(parent_parts)] == parent_parts + def tracked_gitlink(owner: Path, nested: Path, indexed_paths: set[bytes]) -> bool: + relative = nested.relative_to(owner) + if os.fsencode(relative.as_posix()) in indexed_paths: + return True + for indexed in indexed_paths: + components = PurePosixPath(os.fsdecode(indexed)).parts + if len(components) != len(relative.parts): + continue + parent = owner + for indexed_name, materialized in zip(components, relative.parts): + if indexed_name != materialized: + try: + expected = (parent / indexed_name).stat(follow_symlinks=False) + actual = (parent / materialized).stat(follow_symlinks=False) + except OSError: + break + if symbolic_metadata(expected) or symbolic_metadata(actual) or ( + expected.st_dev, + expected.st_ino, + ) != (actual.st_dev, actual.st_ino): + break + parent /= materialized + else: + return True + return False + tracked_gitlinks = [] for owner, _tracked_paths in cached_by_root.values(): staged = run_git(["ls-files", "--sparse", "--stage", "-z"], directory=owner) @@ -1519,7 +1545,7 @@ def exact_descendant(candidate: Path, parent: Path) -> bool: for nested in inspected_roots.values() if nested != owner and exact_descendant(nested, owner) - and os.fsencode(nested.relative_to(owner).as_posix()) in indexed_paths + and tracked_gitlink(owner, nested, indexed_paths) ) def tracked_variants(root: Path, relative: bytes) -> Iterator[Path]: components = PurePosixPath(os.fsdecode(relative)).parts @@ -1566,10 +1592,16 @@ def descend(parent: Path, index: int) -> list[Path]: return [] if symbolic_metadata(expected): return [] + try: + addressed = (parent / component).resolve(strict=True) + except OSError: + return [] variants = [ candidate - for group in directory_entries[parent_identity].values() - for candidate in group + for candidate in directory_entries[parent_identity].get( + indexed_name_key(addressed.name), [] + ) + if candidate.name == addressed.name ] selected_index = len(root_parts) + index if selected_index < len(selected_parts): diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts index d3bdd75f..54bb33f2 100644 --- a/sdk/typescript/tests-ts/scan-inventory.test.ts +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -396,7 +396,7 @@ describe("security scan file inventory", () => { }, ); - test.each([".", "LongDirectory", "LongDirectory/private.ts"])( + test.each([".", "LongDirectory", "LongDirectory/PrivateDocument.ts"])( "restores tracked 8.3 aliases for %s scans using no-follow identity", async (scope) => { if (Bun.which("rg") === null) return; @@ -404,12 +404,18 @@ describe("security scan file inventory", () => { const checkout = await repository(); const indexed = join(checkout, "LONGDI~1"); const materialized = join(checkout, "LongDirectory"); + const indexedLeaf = join(materialized, "PRIVAT~1.TS"); + const addressed = join(materialized, "PrivateDocument.ts"); + const unrelated = join(materialized, "ignored-hardlink.ts"); await mkdir(indexed); - await writeFile(join(indexed, "private.ts"), "tracked\n"); - execFileSync("git", ["add", "LONGDI~1/private.ts"], { cwd: checkout }); + await writeFile(join(indexed, "PRIVAT~1.TS"), "tracked\n"); + execFileSync("git", ["add", "LONGDI~1/PRIVAT~1.TS"], { + cwd: checkout, + }); await rm(indexed, { recursive: true }); await mkdir(materialized); - await writeFile(join(materialized, "private.ts"), "tracked\n"); + await writeFile(addressed, "tracked\n"); + await hardlink(addressed, unrelated); await writeFile(join(checkout, ".gitignore"), "LongDirectory/\n"); const instrumentation = join(dirname(checkout), "instrumentation"); @@ -420,21 +426,34 @@ describe("security scan file inventory", () => { "from pathlib import Path", `indexed = Path(${JSON.stringify(indexed)})`, `materialized = Path(${JSON.stringify(materialized)})`, + `indexed_leaf = Path(${JSON.stringify(indexedLeaf)})`, + `addressed = Path(${JSON.stringify(addressed)})`, "original = Path.stat", + "original_resolve = Path.resolve", "def guarded(self, *args, **kwargs):", " if self == indexed and kwargs.get('follow_symlinks') is False:", " return original(materialized, *args, **kwargs)", + " if self == indexed_leaf and kwargs.get('follow_symlinks') is False:", + " return original(addressed, *args, **kwargs)", " return original(self, *args, **kwargs)", + "def resolved(self, *args, **kwargs):", + " if self == indexed:", + " return original_resolve(materialized, *args, **kwargs)", + " if self == indexed_leaf:", + " return original_resolve(addressed, *args, **kwargs)", + " return original_resolve(self, *args, **kwargs)", "Path.stat = guarded", + "Path.resolve = resolved", ].join("\n"), ); - expect( - await inventory(checkout, scope, { - ...process.env, - PYTHONPATH: instrumentation, - }), - ).toContain(`${scope === "." ? "./" : ""}LongDirectory/private.ts`); + const rows = await inventory(checkout, scope, { + ...process.env, + PYTHONPATH: instrumentation, + }); + const prefix = scope === "." ? "./" : ""; + expect(rows).toContain(`${prefix}LongDirectory/PrivateDocument.ts`); + expect(rows).not.toContain(`${prefix}LongDirectory/ignored-hardlink.ts`); }, ); @@ -682,14 +701,25 @@ describe("security scan file inventory", () => { expect(rows).not.toContain("./middle/nested/private.ts"); }); - test.each(["stage-0", "conflicted"])( + test.each(["stage-0", "conflicted", "short-alias", "conflicted-short-alias"])( "admits %s tracked Gitlinks through configured directory excludes", async (staging) => { if (Bun.which("rg") === null) return; const checkout = await repository(); - const nested = join(checkout, "nested"); + const short = staging.endsWith("short-alias"); + const name = short ? "LongDirectory" : "nested"; + const nested = join(checkout, name); await mkdir(nested); + const nativeAlias = short ? windowsShortPath(nested) : null; + if (short && process.platform === "win32" && nativeAlias === null) { + return; + } + const indexed = short + ? nativeAlias === null + ? "LONGDI~1" + : basename(nativeAlias) + : name; execFileSync("git", ["init", "-q"], { cwd: nested }); await Promise.all([ writeFile(join(nested, "visible.ts"), "visible\n"), @@ -699,11 +729,11 @@ describe("security scan file inventory", () => { cwd: nested, }); commit(nested); - execFileSync("git", ["add", "nested"], { + execFileSync("git", ["add", name], { cwd: checkout, stdio: "ignore", }); - if (staging === "conflicted") { + if (short || staging === "conflicted") { const object = execFileSync("git", ["rev-parse", "HEAD"], { cwd: nested, encoding: "utf8", @@ -711,20 +741,46 @@ describe("security scan file inventory", () => { execFileSync("git", ["update-index", "--index-info"], { cwd: checkout, input: [ - `0 ${"0".repeat(40)}\tnested`, - ...[1, 2, 3].map((stage) => `160000 ${object} ${stage}\tnested`), + `0 ${"0".repeat(40)}\t${name}`, + ...(staging.startsWith("conflicted") ? [1, 2, 3] : [0]).map( + (stage) => `160000 ${object} ${stage}\t${indexed}`, + ), "", ].join("\n"), }); } + if (short && nativeAlias === null) { + await symlink(nested, join(checkout, indexed)); + } await writeFile( join(checkout, ".git", "info", "exclude"), - "nested/\nnested/private.ts\n", + `${name}/\n${name}/private.ts\n${indexed}/\n`, ); - const rows = await inventory(checkout); - expect(rows).toContain("./nested/visible.ts"); - expect(rows).not.toContain("./nested/private.ts"); + let environment = process.env; + if (short) { + const instrumentation = join(dirname(checkout), "instrumentation"); + await mkdir(instrumentation); + await writeFile( + join(instrumentation, "sitecustomize.py"), + [ + "from pathlib import Path", + `indexed = Path(${JSON.stringify(join(checkout, indexed))})`, + `materialized = Path(${JSON.stringify(nested)})`, + "original = Path.stat", + "def guarded(self, *args, **kwargs):", + " if self == indexed and kwargs.get('follow_symlinks') is False:", + " return original(materialized, *args, **kwargs)", + " return original(self, *args, **kwargs)", + "Path.stat = guarded", + ].join("\n"), + ); + environment = { ...process.env, PYTHONPATH: instrumentation }; + } + + const rows = await inventory(checkout, ".", environment); + expect(rows).toContain(`./${name}/visible.ts`); + expect(rows).not.toContain(`./${name}/private.ts`); }, );