#!/usr/bin/env python3 """Supply-chain guards for the template's riskiest surfaces. Run from anywhere: python tools/security_guards.py This repo ships pre-approved Claude Code permissions and CLI code that every fork user executes. These guards make the dangerous changes LOUD, not impossible: a PR that intentionally needs one of them must update the allowlists in this file in the same diff, so the change is explicit and reviewable rather than buried. Checks: 1. .claude/settings.json — every permissions.allow entry must be in the exact allowlist below. Catches permission widening (e.g. Bash(*), Bash(curl:*)), which would auto-approve commands on every fork. 2. .gitignore — the personal-data ignore rules must all still be present. Catches weakening that would make future users silently commit their tracker, profile exports, or application archives. 3. .agents/**/package.json — no npm/bun lifecycle scripts (preinstall, install, postinstall, prepare, prepack) and no trustedDependencies. Catches code execution smuggled into `bun install`. Stdlib only. Exit 0 on success, 1 with a failure list otherwise. """ import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent errors: list[str] = [] # The exact permission entries the template ships. A PR that adds or changes # an entry must add it here too - that is the point: the diff shows both. ALLOWED_PERMISSIONS = { "Skill(job-application-assistant)", "Bash(bun run:*)", "Bash(python salary_lookup.py:*)", "Bash(python3 salary_lookup.py:*)", "Bash(pdftotext:*)", } # Personal-data ignore rules that must never disappear from .gitignore. REQUIRED_IGNORE_RULES = [ "salary_data.json", "job_scraper/seen_jobs.json", "cv/main_*.tex", "!cv/main_example.tex", "cover_letters/cover_*.tex", "documents/cv/**", "documents/linkedin/**", "documents/diplomas/**", "documents/references/**", "documents/applications/**", "job_search_tracker.csv", ] FORBIDDEN_SCRIPTS = {"preinstall", "install", "postinstall", "prepare", "prepack"} def check_permissions() -> None: path = ROOT / ".claude" / "settings.json" try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: errors.append(f".claude/settings.json: unreadable or invalid JSON: {exc}") return allow = data.get("permissions", {}).get("allow", []) for entry in allow: if entry not in ALLOWED_PERMISSIONS: errors.append( f".claude/settings.json: permission not in the reviewed allowlist: {entry!r}. " "Pre-approved permissions run without prompting on every fork. If this entry is " "intentional, add it to ALLOWED_PERMISSIONS in tools/security_guards.py in the " "same PR so the widening is explicit and reviewable." ) for entry in ALLOWED_PERMISSIONS - set(allow): # Not an error: settings may legitimately drop an entry. But an # allowlist entry that no longer exists should be pruned. print(f"note: allowlisted permission not present in settings.json: {entry!r}") def check_gitignore() -> None: path = ROOT / ".gitignore" try: rules = {line.strip() for line in path.read_text(encoding="utf-8").splitlines()} except OSError as exc: errors.append(f".gitignore: unreadable: {exc}") return for rule in REQUIRED_IGNORE_RULES: if rule not in rules: errors.append( f".gitignore: required personal-data rule missing: {rule!r}. " "These rules keep fork users from committing personal data. If the rule moved " "or was renamed intentionally, update REQUIRED_IGNORE_RULES in " "tools/security_guards.py in the same PR." ) def check_package_manifests() -> None: manifests = [ p for p in ROOT.glob(".agents/**/package.json") if "node_modules" not in p.parts ] if not manifests: errors.append(".agents: no package.json files found - glob roots are wrong or the tree moved") for manifest in manifests: relpath = manifest.relative_to(ROOT) try: data = json.loads(manifest.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: errors.append(f"{relpath}: unreadable or invalid JSON: {exc}") continue bad = FORBIDDEN_SCRIPTS & set(data.get("scripts", {})) if bad: errors.append( f"{relpath}: lifecycle script(s) {sorted(bad)} are forbidden - they execute " "arbitrary code during `bun install` on every fork user's machine." ) if "trustedDependencies" in data: errors.append( f"{relpath}: trustedDependencies is forbidden - it re-enables dependency " "lifecycle scripts that bun blocks by default." ) def main() -> int: check_permissions() check_gitignore() check_package_manifests() if errors: print(f"security_guards: {len(errors)} failure(s)") for err in errors: print(f" - {err}") return 1 print("security_guards: OK (permissions allowlist, gitignore rules, package manifests)") return 0 if __name__ == "__main__": sys.exit(main())