From a4fe371f70f922a0709b0786035852d1cebcab80 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 10:06:29 -0700 Subject: [PATCH 1/6] refactor(plugin): share checked security policy inputs --- .../scripts/resolve_security_md.py | 173 +++++++++++---- .../tests-ts/security-policy-inputs.test.ts | 209 ++++++++++++++++++ 2 files changed, 345 insertions(+), 37 deletions(-) create mode 100644 sdk/typescript/tests-ts/security-policy-inputs.test.ts diff --git a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py index 59b12539f..06df620e2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py +++ b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py @@ -34,7 +34,71 @@ def _resolve_root(repo: Path) -> Path: return root -def list_security_md(repo: Path) -> list[str]: +def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False) -> Path: + requested = scope.expanduser() + if not requested.is_absolute(): + requested = root / requested + try: + resolved = requested.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ResolutionError(f"scan scope does not exist: {requested}") from exc + _inside(resolved, root, "scan scope") + if require_directory and not resolved.is_dir(): + raise ResolutionError(f"policy scope must be a directory: {requested}") + return resolved if resolved.is_dir() else resolved.parent + + +def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool: + if any(path.is_relative_to(directory) for directory in git_dirs): + return True + current = root + for part in _inside(path, root, "policy path").parts: + current /= part + if part == ".git": + return True + if part.lower() == ".git": + marker = current.with_name(".git") + try: + if current.samefile(marker): + return True + except (FileNotFoundError, NotADirectoryError): + pass + return False + + +def _read_policy(policy: Path, root: Path, git_dirs: tuple[Path, ...] = ()) -> str | None: + try: + resolved = policy.resolve(strict=False) + except (OSError, RuntimeError) as exc: + raise ResolutionError(f"could not resolve SECURITY.md: {policy}") from exc + _inside(resolved, root, "SECURITY.md") + if _git_metadata(policy, root, git_dirs) or _git_metadata(resolved, root, git_dirs): + raise ResolutionError(f"SECURITY.md points into Git metadata: {policy}") + try: + metadata = resolved.stat(follow_symlinks=False) + except (FileNotFoundError, NotADirectoryError): + return None + if not stat.S_ISREG(metadata.st_mode): + raise ResolutionError(f"SECURITY.md must be a regular file: {policy}") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0) + with os.fdopen(os.open(resolved, flags), "rb") as policy_file: + metadata = os.fstat(policy_file.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise ResolutionError(f"SECURITY.md must be a regular file: {policy}") + if metadata.st_nlink > 1: + raise ResolutionError(f"SECURITY.md must not be hard-linked: {policy}") + policy_bytes = policy_file.read(MAX_SECURITY_MD_BYTES + 1) + if len(policy_bytes) > MAX_SECURITY_MD_BYTES: + raise ResolutionError(f"SECURITY.md exceeds 1 MiB: {policy}") + try: + return policy_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ResolutionError(f"SECURITY.md is not valid UTF-8: {policy}") from exc + + +def list_security_md( + repo: Path, scope: Path | None = None, git_dirs: tuple[Path, ...] = () +) -> list[str]: """Return a stable, safely framed inventory without traversing Git metadata.""" root = _resolve_root(repo) @@ -42,14 +106,18 @@ def raise_walk_error(error: OSError) -> None: raise error policies: list[str] = [] - for directory, subdirectories, filenames in os.walk( - root, onerror=raise_walk_error, followlinks=False + selected = root if scope is None else _scope_directory(root, scope, require_directory=True) + if _git_metadata(selected, root, git_dirs): + raise ResolutionError(f"policy scope is inside Git metadata: {selected}") + for directory, subdirectories, _filenames in os.walk( + selected, onerror=raise_walk_error, followlinks=False ): safe_subdirectories: list[str] = [] for name in sorted(subdirectories): - if name == ".git": + child = Path(directory) / name + if _git_metadata(child, root, git_dirs): continue - directory_stat = (Path(directory) / name).stat(follow_symlinks=False) + directory_stat = child.stat(follow_symlinks=False) if not stat.S_ISDIR(directory_stat.st_mode): continue reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) @@ -57,28 +125,19 @@ def raise_walk_error(error: OSError) -> None: continue safe_subdirectories.append(name) subdirectories[:] = safe_subdirectories - if "SECURITY.md" not in filenames: - continue policy = Path(directory) / "SECURITY.md" if policy.is_file() or policy.is_symlink(): policies.append(policy.relative_to(root).as_posix()) return sorted(policies) -def resolve_security_md(repo: Path, scope: Path) -> str: +def resolve_security_md(repo: Path, scope: Path, git_dirs: tuple[Path, ...] = ()) -> str: """Return applicable SECURITY.md files, concatenated root to leaf.""" root = _resolve_root(repo) - requested_scope = scope.expanduser() - if not requested_scope.is_absolute(): - requested_scope = root / requested_scope - try: - resolved_scope = requested_scope.resolve(strict=True) - except OSError as exc: - raise ResolutionError(f"scan scope does not exist: {requested_scope}") from exc - _inside(resolved_scope, root, "scan scope") - - target_directory = resolved_scope if resolved_scope.is_dir() else resolved_scope.parent + target_directory = _scope_directory(root, scope) + if _git_metadata(target_directory, root, git_dirs): + raise ResolutionError(f"policy scope is inside Git metadata: {target_directory}") relative_directory = _inside(target_directory, root, "scan scope") directories = [root] current = root @@ -89,18 +148,9 @@ def resolve_security_md(repo: Path, scope: Path) -> str: sections: list[str] = [] for directory in directories: policy = directory / "SECURITY.md" - if not policy.is_file(): + content = _read_policy(policy, root, git_dirs) + if content is None: continue - resolved_policy = policy.resolve(strict=True) - _inside(resolved_policy, root, "SECURITY.md") - try: - with resolved_policy.open("rb") as policy_file: - policy_bytes = policy_file.read(MAX_SECURITY_MD_BYTES + 1) - if len(policy_bytes) > MAX_SECURITY_MD_BYTES: - raise ResolutionError(f"SECURITY.md exceeds 1 MiB: {policy}") - content = policy_bytes.decode("utf-8") - except UnicodeDecodeError as exc: - raise ResolutionError(f"SECURITY.md is not valid UTF-8: {policy}") from exc if not content.strip(): continue @@ -113,23 +163,62 @@ def resolve_security_md(repo: Path, scope: Path) -> str: return "\n".join(sections) +def inspect_security_policy( + repo: Path, scope: Path, git_dirs: tuple[Path, ...] = () +) -> dict[str, object]: + """Return checked drafting evidence without interpreting policy as instructions.""" + root = _resolve_root(repo) + directory = _scope_directory(root, scope, require_directory=True) + selected = directory / "SECURITY.md" + if selected.is_symlink(): + raise ResolutionError(f"selected SECURITY.md must not be a symbolic link: {selected}") + previous = _read_policy(selected, root, git_dirs) + paths = set(list_security_md(root, directory, git_dirs)) + current = directory + while True: + paths.add((current / "SECURITY.md").relative_to(root).as_posix()) + if current == root: + break + current = current.parent + paths.update((".github/SECURITY.md", "docs/SECURITY.md")) + checked = [ + path for path in sorted(paths) if _read_policy(root / path, root, git_dirs) is not None + ] + return { + "previousContent": previous, + "guidance": resolve_security_md(root, directory, git_dirs), + "policyPaths": checked, + } + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", required=True, type=Path, help="scan root directory") - parser.add_argument( + mode = parser.add_mutually_exclusive_group() + mode.add_argument( "--list", action="store_true", help="write a JSON inventory of repository policy paths", ) + mode.add_argument( + "--inspect", + action="store_true", + help="write checked drafting inputs as JSON", + ) parser.add_argument( "--scope", type=Path, help="existing file or directory within the scan root", ) parser.add_argument("--out", default=Path("-"), type=Path, help="output path, or - for stdout") + parser.add_argument( + "--git-dir", + action="append", + default=[], + type=Path, + help="exclude a Git metadata directory identified by the caller", + ) args = parser.parse_args() - if args.list and args.scope is not None: - parser.error("--list cannot be combined with --scope") if not args.list and args.scope is None: parser.error("--scope is required unless --list is specified") return args @@ -138,11 +227,21 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() try: - guidance = ( - json.dumps(list_security_md(args.repo), ensure_ascii=True) + "\n" - if args.list - else resolve_security_md(args.repo, args.scope) - ) + git_dirs = tuple(path.resolve(strict=True) for path in args.git_dir) + if args.inspect: + guidance = ( + json.dumps( + inspect_security_policy(args.repo, args.scope, git_dirs), ensure_ascii=True + ) + + "\n" + ) + elif args.list: + guidance = ( + json.dumps(list_security_md(args.repo, args.scope, git_dirs), ensure_ascii=True) + + "\n" + ) + else: + guidance = resolve_security_md(args.repo, args.scope, git_dirs) if args.out == Path("-"): sys.stdout.buffer.write(guidance.encode("utf-8")) else: diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts new file mode 100644 index 000000000..c9083beb1 --- /dev/null +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -0,0 +1,209 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + link, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + 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 python = execFileSync( + process.env["PYTHON"] ?? + (process.platform === "win32" ? "python" : "python3"), + ["-c", "import sys; print(sys.executable)"], + { encoding: "utf8" }, +).trim(); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), "policy-inputs-"))); + roots.push(root); + const repository = join(root, "repository"); + await mkdir(repository); + return { root, repository }; +} + +function run(repository: string, ...args: string[]) { + return spawnSync( + python, + [ + "-I", + join(PLUGIN_ROOT, "scripts", "resolve_security_md.py"), + "--repo", + repository, + ...args, + ], + { encoding: "utf8" }, + ); +} + +function inspect(repository: string, scope = ".", ...args: string[]) { + const result = run(repository, "--inspect", "--scope", scope, ...args); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as { + previousContent: string | null; + guidance: string; + policyPaths: string[]; + }; +} + +describe("shared security-policy inputs", () => { + test("returns scoped policy evidence and keeps reporting policies separate", async () => { + const { repository } = await fixture(); + for (const path of [ + "services/api/.hidden", + "services/other", + ".github", + "docs", + ]) + await mkdir(join(repository, path), { recursive: true }); + for (const [path, content] of [ + ["SECURITY.md", "# Root policy\n"], + ["services/api/SECURITY.md", "# API policy\r\n"], + ["services/api/.hidden/SECURITY.md", "# Hidden policy\n"], + ["services/other/SECURITY.md", "# Other policy\n"], + [".github/SECURITY.md", "# Reporting instructions\n"], + ["docs/SECURITY.md", "# Reporting documentation\n"], + ]) + await writeFile(join(repository, path!), content!); + + const result = inspect(repository, "services/api"); + expect(result.previousContent).toBe("# API policy\r\n"); + expect(result.guidance.indexOf("# Root policy")).toBeLessThan( + result.guidance.indexOf("# API policy"), + ); + expect(result.guidance).not.toContain("Reporting instructions"); + expect(result.guidance).not.toContain("Other policy"); + expect(result.policyPaths).toEqual([ + ".github/SECURITY.md", + "SECURITY.md", + "docs/SECURITY.md", + "services/api/.hidden/SECURITY.md", + "services/api/SECURITY.md", + ]); + expect( + JSON.parse(run(repository, "--list", "--scope", "services/api").stdout), + ).toEqual(["services/api/.hidden/SECURITY.md", "services/api/SECURITY.md"]); + }); + + test("reads safe inherited links but rejects a linked destination", async () => { + const { repository } = await fixture(); + await mkdir(join(repository, "component")); + await writeFile(join(repository, "guidance.md"), "# Shared guidance\n"); + await symlink("guidance.md", join(repository, "SECURITY.md"), "file"); + expect(inspect(repository, "component").guidance).toContain( + "# Shared guidance", + ); + const selected = run(repository, "--inspect", "--scope", "."); + expect(selected.status).toBe(2); + expect(selected.stderr).toContain( + "selected SECURITY.md must not be a symbolic link", + ); + }); + + test("rejects outside, dangling outside, cyclic, and hard-linked evidence", async () => { + for (const kind of ["outside", "missing", "cycle", "hard-link"]) { + const { root, repository } = await fixture(); + await mkdir(join(repository, "component")); + const outside = join(root, "outside.md"); + const policy = join(repository, "component", "SECURITY.md"); + await writeFile(outside, "synthetic private text\n"); + if (kind === "hard-link") await link(outside, policy); + else + await symlink( + kind === "outside" + ? outside + : kind === "missing" + ? join(root, "missing.md") + : policy, + policy, + "file", + ); + const result = run(repository, "--inspect", "--scope", "."); + expect(result.status, kind).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toContain("synthetic private text"); + } + }); + + test("does not traverse directory links or Git metadata", async () => { + const { root, repository } = await fixture(); + const outside = join(root, "outside"); + const metadata = join(repository, "git-data"); + await mkdir(outside); + await mkdir(join(repository, ".git")); + await mkdir(metadata); + await writeFile(join(outside, "SECURITY.md"), "# Outside\n"); + await writeFile( + join(repository, ".git", "SECURITY.md"), + "# Git metadata\n", + ); + await writeFile(join(metadata, "SECURITY.md"), "# Separate Git metadata\n"); + await symlink( + outside, + join(repository, "linked-directory"), + process.platform === "win32" ? "junction" : "dir", + ); + expect(inspect(repository, ".", "--git-dir", metadata).policyPaths).toEqual( + [], + ); + await mkdir(join(repository, "component")); + await symlink( + join(metadata, "SECURITY.md"), + join(repository, "component", "SECURITY.md"), + "file", + ); + const result = run( + repository, + "--inspect", + "--scope", + ".", + "--git-dir", + metadata, + ); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Git metadata"); + }); + + test("enforces the existing byte and UTF-8 contract in both resolver modes", async () => { + for (const content of [ + Buffer.alloc(1024 * 1024 + 1, "x"), + Buffer.from([0xff]), + ]) { + const { repository } = await fixture(); + await writeFile(join(repository, "SECURITY.md"), content); + for (const mode of [[], ["--inspect"]]) { + const result = run(repository, ...mode, "--scope", "."); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + } + } + }); + + test("requires a directory scope and never writes the repository", async () => { + const { root, repository } = await fixture(); + const source = join(repository, "source.ts"); + await writeFile(source, "export const value = 1;\n"); + expect(run(repository, "--inspect", "--scope", source).status).toBe(2); + expect(run(repository, "--inspect", "--scope", root).status).toBe(2); + expect(inspect(repository)).toEqual({ + previousContent: null, + guidance: "", + policyPaths: [], + }); + expect(await readFile(source, "utf8")).toBe("export const value = 1;\n"); + }); +}); From 2488778277e489eee1b057b5abd34923bb5032df Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 10:26:11 -0700 Subject: [PATCH 2/6] fix(plugin): reject Git metadata path aliases --- .../scripts/resolve_security_md.py | 17 ++++++++-- .../tests-ts/security-policy-inputs.test.ts | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py index 06df620e2..fadcff20d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py +++ b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py @@ -49,10 +49,21 @@ def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool: - if any(path.is_relative_to(directory) for directory in git_dirs): - return True + relative = _inside(path, root, "policy path") + for directory in git_dirs: + if path.is_relative_to(directory): + return True + # resolve() preserves case aliases on some case-insensitive filesystems. + for ancestor in (path, *path.parents): + try: + if ancestor.samefile(directory): + return True + except (FileNotFoundError, NotADirectoryError): + pass + if ancestor == root: + break current = root - for part in _inside(path, root, "policy path").parts: + for part in relative.parts: current /= part if part == ".git": return True diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts index c9083beb1..ef46f05a8 100644 --- a/sdk/typescript/tests-ts/security-policy-inputs.test.ts +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -6,6 +6,7 @@ import { readFile, realpath, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -178,6 +179,37 @@ describe("shared security-policy inputs", () => { expect(result.stderr).toContain("Git metadata"); }); + test("rejects case aliases of caller-supplied Git metadata directories", async () => { + const { repository } = await fixture(); + const metadata = join(repository, "GitData"); + const alias = join(repository, "gitdata"); + await mkdir(metadata); + await mkdir(join(repository, "component")); + await writeFile(join(metadata, "private.md"), "synthetic metadata\n"); + if ((await stat(alias).catch(() => null)) === null) + await symlink( + metadata, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + await symlink( + join(alias, "private.md"), + join(repository, "SECURITY.md"), + "file", + ); + const result = run( + repository, + "--inspect", + "--scope", + "component", + "--git-dir", + metadata, + ); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Git metadata"); + }); + test("enforces the existing byte and UTF-8 contract in both resolver modes", async () => { for (const content of [ Buffer.alloc(1024 * 1024 + 1, "x"), From e0191aa3f4f83133260dbe4b0a0973aaaff57b0c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 10:27:47 -0700 Subject: [PATCH 3/6] test(plugin): reuse supported Python discovery --- sdk/typescript/tests-ts/security-policy-inputs.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts index ef46f05a8..d6ef29456 100644 --- a/sdk/typescript/tests-ts/security-policy-inputs.test.ts +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { link, mkdir, @@ -13,14 +13,10 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; +import { resolvePluginPython } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; -const python = execFileSync( - process.env["PYTHON"] ?? - (process.platform === "win32" ? "python" : "python3"), - ["-c", "import sys; print(sys.executable)"], - { encoding: "utf8" }, -).trim(); +const python = await resolvePluginPython(); const roots: string[] = []; afterEach(async () => { From 5c70219f19be6c39f0d593fd60115024e17d80c1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 10:29:35 -0700 Subject: [PATCH 4/6] fix(plugin): normalize cyclic Git-directory errors --- .../_bundled_plugin/scripts/resolve_security_md.py | 2 +- sdk/typescript/tests-ts/security-policy-inputs.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py index fadcff20d..ca808d297 100644 --- a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py +++ b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py @@ -258,7 +258,7 @@ def main() -> int: else: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(guidance, encoding="utf-8") - except (OSError, ResolutionError) as exc: + except (OSError, RuntimeError, ResolutionError) as exc: print(f"resolve_security_md.py: error: {exc}", file=sys.stderr) return 2 return 0 diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts index d6ef29456..ad10985ae 100644 --- a/sdk/typescript/tests-ts/security-policy-inputs.test.ts +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -227,6 +227,11 @@ describe("shared security-policy inputs", () => { await writeFile(source, "export const value = 1;\n"); expect(run(repository, "--inspect", "--scope", source).status).toBe(2); expect(run(repository, "--inspect", "--scope", root).status).toBe(2); + const loop = join(root, "git-loop"); + await symlink(loop, loop, "file"); + const invalidMetadata = run(repository, "--list", "--git-dir", loop); + expect(invalidMetadata.status).toBe(2); + expect(invalidMetadata.stderr).not.toContain("Traceback"); expect(inspect(repository)).toEqual({ previousContent: null, guidance: "", From 687e943bcb309d8024d5e8122818ef4b9251aedd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 10:51:26 -0700 Subject: [PATCH 5/6] fix(plugin): honor case-sensitive Windows directories --- .../scripts/resolve_security_md.py | 4 +--- .../tests-ts/security-policy-inputs.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py index ca808d297..f72640dfd 100644 --- a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py +++ b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py @@ -51,9 +51,7 @@ def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool: relative = _inside(path, root, "policy path") for directory in git_dirs: - if path.is_relative_to(directory): - return True - # resolve() preserves case aliases on some case-insensitive filesystems. + # Path spelling does not reliably describe filesystem case sensitivity. for ancestor in (path, *path.parents): try: if ancestor.samefile(directory): diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts index ad10985ae..e52bec87d 100644 --- a/sdk/typescript/tests-ts/security-policy-inputs.test.ts +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -206,6 +206,29 @@ describe("shared security-policy inputs", () => { expect(result.stderr).toContain("Git metadata"); }); + test("does not confuse distinct case-sensitive Windows directories", () => { + const result = spawnSync( + python, + [ + "-I", + "-c", + `import runpy, sys +from pathlib import PureWindowsPath +class CaseSensitivePath(PureWindowsPath): + def samefile(self, other): + return str(self) == str(other) +guard = runpy.run_path(sys.argv[1])["_git_metadata"] +root = CaseSensitivePath("C:/repo") +metadata = root / "GitData" +assert not guard(root / "gitdata" / "SECURITY.md", root, (metadata,)) +assert guard(metadata / "SECURITY.md", root, (metadata,))`, + join(PLUGIN_ROOT, "scripts", "resolve_security_md.py"), + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + }); + test("enforces the existing byte and UTF-8 contract in both resolver modes", async () => { for (const content of [ Buffer.alloc(1024 * 1024 + 1, "x"), From d9e69cfaebcca679312571689410d8b57f2c36ae Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 18 Aug 2026 11:14:09 -0700 Subject: [PATCH 6/6] fix(plugin): resolve policy containment by filesystem identity --- .../scripts/resolve_security_md.py | 33 ++++++++++--------- .../tests-ts/security-policy-inputs.test.ts | 21 ++++++++++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py index f72640dfd..1c294c9c7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py +++ b/sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py @@ -17,11 +17,22 @@ class ResolutionError(ValueError): """Raised when a SECURITY.md chain cannot be resolved.""" +def _relative_to(path: Path, root: Path) -> Path | None: + """Use filesystem identity, including case-sensitive Windows directories.""" + for ancestor in (path, *path.parents): + try: + if ancestor.samefile(root): + return path.relative_to(ancestor) + except (FileNotFoundError, NotADirectoryError): + pass + return None + + def _inside(path: Path, root: Path, label: str) -> Path: - try: - return path.relative_to(root) - except ValueError as exc: - raise ResolutionError(f"{label} is outside the scan root: {path}") from exc + relative = _relative_to(path, root) + if relative is None: + raise ResolutionError(f"{label} is outside the scan root: {path}") + return relative def _resolve_root(repo: Path) -> Path: @@ -42,7 +53,7 @@ def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False resolved = requested.resolve(strict=True) except (OSError, RuntimeError) as exc: raise ResolutionError(f"scan scope does not exist: {requested}") from exc - _inside(resolved, root, "scan scope") + resolved = root / _inside(resolved, root, "scan scope") if require_directory and not resolved.is_dir(): raise ResolutionError(f"policy scope must be a directory: {requested}") return resolved if resolved.is_dir() else resolved.parent @@ -50,16 +61,8 @@ def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool: relative = _inside(path, root, "policy path") - for directory in git_dirs: - # Path spelling does not reliably describe filesystem case sensitivity. - for ancestor in (path, *path.parents): - try: - if ancestor.samefile(directory): - return True - except (FileNotFoundError, NotADirectoryError): - pass - if ancestor == root: - break + if any(_relative_to(path, directory) is not None for directory in git_dirs): + return True current = root for part in relative.parts: current /= part diff --git a/sdk/typescript/tests-ts/security-policy-inputs.test.ts b/sdk/typescript/tests-ts/security-policy-inputs.test.ts index e52bec87d..93357d9c7 100644 --- a/sdk/typescript/tests-ts/security-policy-inputs.test.ts +++ b/sdk/typescript/tests-ts/security-policy-inputs.test.ts @@ -173,6 +173,14 @@ describe("shared security-policy inputs", () => { ); expect(result.status).toBe(2); expect(result.stderr).toContain("Git metadata"); + const nestedMetadata = join(metadata, "hooks"); + await mkdir(nestedMetadata); + await writeFile(join(nestedMetadata, "SECURITY.md"), "# Metadata\n"); + for (const mode of [["--list"], ["--inspect", "--scope", "."]]) { + const nested = run(nestedMetadata, ...mode, "--git-dir", metadata); + expect(nested.status).toBe(2); + expect(nested.stdout).toBe(""); + } }); test("rejects case aliases of caller-supplied Git metadata directories", async () => { @@ -206,7 +214,7 @@ describe("shared security-policy inputs", () => { expect(result.stderr).toContain("Git metadata"); }); - test("does not confuse distinct case-sensitive Windows directories", () => { + test("uses filesystem identity for case-sensitive Windows containment", () => { const result = spawnSync( python, [ @@ -217,11 +225,18 @@ from pathlib import PureWindowsPath class CaseSensitivePath(PureWindowsPath): def samefile(self, other): return str(self) == str(other) -guard = runpy.run_path(sys.argv[1])["_git_metadata"] +module = runpy.run_path(sys.argv[1]) +guard = module["_git_metadata"] root = CaseSensitivePath("C:/repo") metadata = root / "GitData" assert not guard(root / "gitdata" / "SECURITY.md", root, (metadata,)) -assert guard(metadata / "SECURITY.md", root, (metadata,))`, +assert guard(metadata / "SECURITY.md", root, (metadata,)) +try: + module["_inside"](CaseSensitivePath("C:/Repo/SECURITY.md"), root, "scope") +except module["ResolutionError"]: + pass +else: + raise AssertionError("accepted a distinct case-only sibling")`, join(PLUGIN_ROOT, "scripts", "resolve_security_md.py"), ], { encoding: "utf8" },