Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a4fe371
refactor(plugin): share checked security policy inputs
mldangelo-oai Aug 18, 2026
6229e14
Merge remote-tracking branch 'origin/main' into mdangelo/codex/policy…
mldangelo-oai Aug 18, 2026
2488778
fix(plugin): reject Git metadata path aliases
mldangelo-oai Aug 18, 2026
e0191aa
test(plugin): reuse supported Python discovery
mldangelo-oai Aug 18, 2026
5c70219
fix(plugin): normalize cyclic Git-directory errors
mldangelo-oai Aug 18, 2026
687e943
fix(plugin): honor case-sensitive Windows directories
mldangelo-oai Aug 18, 2026
d91b929
Merge remote-tracking branch 'origin/main' into mdangelo/codex/policy…
mldangelo-oai Aug 18, 2026
d9e69cf
fix(plugin): resolve policy containment by filesystem identity
mldangelo-oai Aug 18, 2026
30a9b3f
Merge branch 'main' into mdangelo/codex/policy-inputs
mldangelo-oai Aug 20, 2026
d8f01c0
fix(plugin): preserve read-only policy inputs
mldangelo-oai Aug 20, 2026
4c5bbf3
Merge main into policy inputs
mldangelo-oai Aug 20, 2026
119439b
fix(plugin): refresh checked policy inputs
mldangelo-oai Aug 20, 2026
61bfbdf
fix(plugin): reject broken policy links during inspection
mldangelo-oai Aug 21, 2026
5bea2ee
fix(plugin): preserve read-only policy filtering
mldangelo-oai Aug 21, 2026
02ba96c
Merge main into policy inputs
mldangelo-oai Aug 21, 2026
bdc337f
Merge remote-tracking branch 'origin/main' into HEAD
mldangelo-oai Aug 21, 2026
8e2a444
fix(plugin): exclude linked directories from policy lists
mldangelo-oai Aug 22, 2026
8b5a16a
Merge remote-tracking branch 'origin/main' into campaign/pr-564
mldangelo-oai Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 153 additions & 42 deletions sdk/typescript/_bundled_plugin/scripts/resolve_security_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -34,51 +45,111 @@ 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
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


def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool:
relative = _inside(path, root, "policy path")
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
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")
Comment thread
mldangelo-oai marked this conversation as resolved.
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
Comment thread
mldangelo-oai marked this conversation as resolved.
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}")
Comment thread
mldangelo-oai marked this conversation as resolved.
Outdated
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)

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)
if getattr(directory_stat, "st_file_attributes", 0) & reparse_point:
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())
Comment on lines 156 to 157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep directory links out of the read-only inventory

When a repository contains a SECURITY.md symlink whose target is a directory, os.walk classifies that entry as a subdirectory, but this unconditional path probe now adds it because policy.is_symlink() is true. The legacy --list implementation skipped this case via the filenames check, and the documented inventory promises linked policy files rather than directory links; callers can therefore receive a non-file as a policy candidate. Preserve the strict inspection rejection while filtering directory links from read-only --list output.

Useful? React with 👍 / 👎.

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
Expand All @@ -89,18 +160,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

Expand All @@ -113,23 +175,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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate descendant directory policy entries

When --inspect --scope component encounters a descendant such as component/child/SECURITY.md that is a directory or a symlink/junction to one, os.walk classifies it as a subdirectory and the read-only inventory deliberately filters it out, so reusing that inventory here makes inspection succeed with the entry absent from policyPaths instead of enforcing the advertised regular-file and containment checks. Fresh evidence at this exact head is that the new regression covers only a directory link at the selected destination, which _read_policy(..., editable=True) rejects before this inventory runs; the same link below the selected scope is silently omitted. Keep the --list filtering, but separately surface these descendant entries for strict inspection validation.

AGENTS.md reference: sdk/typescript/AGENTS.md:L22-L24

Useful? React with 👍 / 👎.

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",
)
Comment thread
mldangelo-oai marked this conversation as resolved.
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
Expand All @@ -138,17 +239,27 @@ 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:
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
Expand Down
Loading
Loading