Files
ai-job-search/tools/security_guards.py
T
Muhammad HaseebandClaude Opus 5 a7ac6fea75 fix(security): ignore .env so a generated portal skill's API token can't be committed (#303)
/add-portal can generate a skill for a portal that only returns usable
content through a paid fetching service, and such a skill reads its API
token from the environment. Nothing stopped the `.env` holding that token
from being committed: `.gitignore` had no `.env` rule, and
`REQUIRED_IGNORE_RULES` in tools/security_guards.py did not pin one.

No shipped portal needs a credential - all six are free and
unauthenticated - so upstream has never hit this. A fork whose generated
portals do need one hits it on the first `git add -A`.

Add `.env` and `.env.*` to `.gitignore`, and pin both in
`REQUIRED_IGNORE_RULES` so the guard fails if the rule is later dropped.
No negation rule is added, so `ALLOWED_IGNORE_NEGATIONS` is untouched.

Verified:
  - `printf 'X=y' > .env && git check-ignore -v .env` -> matched
  - dropping the `.env` line makes `python3 tools/security_guards.py`
    report the missing rule and fail; restoring it returns OK
  - `lint_skills`, `check_framework_version`, `security_guards` all OK;
    `python3 -m unittest discover -s tests` 196 passed

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:49:40 +02:00

204 lines
8.3 KiB
Python

#!/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,
and no un-allowlisted negation (!pattern) may re-include them. 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",
# Depth-independent: the job-scraper skill resolves `job_scraper/` relative
# to its own directory, so the state file lands under .claude/skills/... and
# a repo-rooted rule silently fails to match it.
"**/job_scraper/seen_jobs.json",
"**/job_scraper/notion_sync.json",
"**/job_scraper/*.md",
"*_BehavioralReport.pdf",
"linkedin_Profile.pdf",
"cv/main_*.*",
"!cv/main_example.tex",
# ATS text extractions (/apply step 5d) carry the CV's full text.
"cv/*.txt",
"cover_letters/cover_*.*",
# /apply also recognizes the uppercase Cover_* naming variant.
"cover_letters/Cover_*.*",
"documents/cv/**",
"documents/linkedin/**",
"documents/diplomas/**",
"documents/references/**",
"documents/applications/**",
"documents/postings/**",
"documents/interview/**",
"job_search_tracker.csv",
"gmail_sync/",
"reports/",
"upskill/*.md",
# Not personal data but the same failure mode: /add-portal can generate a
# skill for a portal that only returns usable content through a paid
# fetching service, and that skill reads an API token from the environment.
".env",
".env.*",
]
# Negation (re-include) rules the template legitimately ships. .gitignore is
# order-sensitive: a later `!pattern` re-includes a path an earlier rule
# excluded, so a rule can be physically present in REQUIRED_IGNORE_RULES yet
# no longer ignored (e.g. adding `!salary_data.json`). Set membership on the
# required rules cannot see that. Any negation outside this allowlist is a
# failure - add an intentional one here in the same PR, exactly as with
# ALLOWED_PERMISSIONS, so the widening is explicit and reviewable.
ALLOWED_IGNORE_NEGATIONS = {
"!cover_letters/OpenFonts/fonts/**",
"!cv/main_example.tex",
"!cover_letters/cover_example.tex",
"!documents/**/.gitkeep",
}
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
if not isinstance(data, dict):
errors.append(".claude/settings.json: top-level JSON value must be an object")
return
permissions = data.get("permissions", {})
if not isinstance(permissions, dict):
errors.append(".claude/settings.json: permissions must be an object")
return
allow = permissions.get("allow", [])
if not isinstance(allow, list) or not all(isinstance(entry, str) for entry in allow):
errors.append(".claude/settings.json: permissions.allow must be a list of strings")
return
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:
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()]
except OSError as exc:
errors.append(f".gitignore: unreadable: {exc}")
return
rules = set(lines)
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."
)
for line in lines:
if line.startswith("!") and line not in ALLOWED_IGNORE_NEGATIONS:
errors.append(
f".gitignore: negation rule not in the reviewed allowlist: {line!r}. "
"A negation re-includes a path an earlier rule excluded and can silently "
"re-expose personal data (a required ignore rule stays present but stops "
"taking effect). If this negation is intentional, add it to "
"ALLOWED_IGNORE_NEGATIONS 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
if not isinstance(data, dict):
errors.append(f"{relpath}: top-level JSON value must be an object")
continue
scripts = data.get("scripts", {})
if not isinstance(scripts, dict):
errors.append(f"{relpath}: scripts must be an object")
continue
bad = FORBIDDEN_SCRIPTS & set(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())