From 45f56e891a89f2611e1dd2bdbfa91a92a878ff69 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 7 Aug 2026 22:50:33 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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"); + }); });