#!/usr/bin/env python3 """ policy-check.py — AI change control guardrails. Reads .ai/policy.json. Scans files. Reports violations. Exits non-zero on failure. Modes: (default) scan staged files if .git exists, otherwise scan source_paths --all scan everything under source_paths (audit / non-git repos) --staged force staged-only mode (requires git repo) --file scan a single file Usage: python3 scripts/policy-check.py python3 scripts/policy-check.py --all python3 scripts/policy-check.py --file resources/views/pages/home.blade.php Exit codes: 0 = no violations 1 = violations found 2 = configuration error Zero dependencies. Python 3 standard library only. """ import argparse import json import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent POLICY_FILE = ROOT / ".ai" / "policy.json" def load_policy(): if not POLICY_FILE.exists(): print(f"error: {POLICY_FILE} not found", file=sys.stderr) sys.exit(2) try: return json.loads(POLICY_FILE.read_text(encoding="utf-8")) except json.JSONDecodeError as e: print(f"error: {POLICY_FILE} is not valid JSON: {e}", file=sys.stderr) sys.exit(2) def is_git_repo(): return (ROOT / ".git").exists() def staged_files(): r = subprocess.run( ["git", "diff", "--cached", "--name-only"], capture_output=True, text=True, check=True, cwd=ROOT, ) return [p.strip() for p in r.stdout.splitlines() if p.strip()] def is_forbidden(rel, forbidden_paths): return any(fp in rel for fp in forbidden_paths) def in_source_paths(rel, source_paths): """A path is in scope if it sits under one of the configured source_paths. Keeps staged-mode consistent with --all (walk_source_paths), so the gate only covers the code you configured it for. Anything outside source_paths (build output, tooling, a frontend with its own gate) is left alone.""" return any(rel == sp or rel.startswith(sp.rstrip("/") + "/") for sp in source_paths) def walk_source_paths(source_paths, forbidden_paths): files = [] for sp in source_paths: base = ROOT / sp if not base.exists(): continue for p in base.rglob("*"): if not p.is_file(): continue rel = str(p.relative_to(ROOT)).replace("\\", "/") if is_forbidden(rel, forbidden_paths): continue files.append(rel) return files def scan_patterns(content, patterns, label, rel, violations): for pat in patterns: try: for m in re.finditer(pat, content, re.MULTILINE): line_no = content.count("\n", 0, m.start()) + 1 violations.append(f"{rel}:{line_no} {label} `{pat}`") except re.error as e: violations.append(f"policy.json invalid regex `{pat}` ({e})") def scan_file(rel, policy, violations): path = ROOT / rel if not path.exists() or path.is_dir(): return # forbidden_paths means "don't scan" — not "violation to edit". # Gitignore handles vendor/build/node_modules edit prevention. # This is a code quality check: it ignores non-code paths in both modes. forbidden_paths = policy["universal"].get("forbidden_paths", []) if is_forbidden(rel, forbidden_paths): return try: content = path.read_text(encoding="utf-8", errors="ignore") except Exception as e: violations.append(f"{rel} unreadable ({e})") return scan_patterns(content, policy["universal"].get("banned_patterns", []), "universal", rel, violations) scan_patterns(content, policy.get("project", {}).get("banned_literals", []), "literal ", rel, violations) scan_patterns(content, policy.get("project", {}).get("banned_imports", []), "import ", rel, violations) scan_patterns(content, policy.get("project", {}).get("banned_usages", []), "usage ", rel, violations) for override in policy.get("overrides", []): override_paths = override.get("paths", []) if not any(op in rel for op in override_paths): continue scan_patterns(content, override.get("banned_patterns", []), "scoped ", rel, violations) scan_patterns(content, override.get("banned_usages", []), "scoped ", rel, violations) allowed_exts = override.get("allowed_extensions") if allowed_exts and not any(rel.endswith(ext) for ext in allowed_exts): violations.append(f"{rel} extension not allowed in this path") def main(): parser = argparse.ArgumentParser(description="AI change control policy checker") group = parser.add_mutually_exclusive_group() group.add_argument("--all", action="store_true", help="scan everything under source_paths") group.add_argument("--staged", action="store_true", help="force staged-only mode") group.add_argument("--file", help="scan a single file") args = parser.parse_args() policy = load_policy() source_paths = policy.get("source_paths", ["app", "resources", "routes", "config", "tests", "src"]) forbidden_paths = policy["universal"].get("forbidden_paths", []) if args.file: files = [args.file.replace("\\", "/")] mode = "file" elif args.all: files = walk_source_paths(source_paths, forbidden_paths) mode = "all" elif args.staged: if not is_git_repo(): print("error: --staged requires a git repository", file=sys.stderr) sys.exit(2) files = [f for f in staged_files() if in_source_paths(f, source_paths)] mode = "staged" elif is_git_repo(): files = [f for f in staged_files() if in_source_paths(f, source_paths)] mode = "staged" else: files = walk_source_paths(source_paths, forbidden_paths) mode = "all (no git)" violations = [] for rel in files: scan_file(rel, policy, violations) if violations: print(f"\nPolicy check FAILED — {len(violations)} violation(s) across {len(files)} file(s) [mode: {mode}]\n") for v in violations: print(f" {v}") print() sys.exit(1) print(f"Policy check passed — {len(files)} file(s) scanned [mode: {mode}]") sys.exit(0) if __name__ == "__main__": main()