From 7227f53a2bd7786ecf9ca368725ed14301da7b88 Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:58:31 +0100 Subject: [PATCH 1/4] Org audit tools: org_settings_audit, org_audit_driver, org_inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three read-only org-wide tools and their tool-hub route rows. - org_settings_audit.py: audits GitHub organization-level settings (not one repo). - org_audit_driver.py: drives the per-repo audits across an org and records one result file per repo. - org_inventory.py: dated TSV inventory of every repo in one or more orgs; reconciles added and removed repos against a previous run; marks repos without a fresh non-empty `__.*` result as NOT CHECKED. Fails closed with exit 2 on any API, scope or unexpected error, an empty listing, a count that disagrees with the org totals, a malformed --previous, a non-https --api-url or an off-host Link; follows the Link header with a 1000-page cap and never sends the token to another host. Closes #2278 Closes #2280 Closes #2281 Tests: 50/50 size-budget-raise: .claude/skills/deep-code-review/references/tool-hub.md 17473→19414 org settings audit, org audit driver and org inventory route rows Co-Authored-By: Claude Opus 5.5 --- .../scripts/org_settings_audit.py | 152 +++++++++++++++ .../deep-code-review/references/tool-hub.md | 6 + .../scripts/org_audit_driver.py | 137 ++++++++++++++ .../deep-code-review/scripts/org_inventory.py | 174 ++++++++++++++++++ changelog.d/org-audit-driver.md | 2 + changelog.d/org-audit-tools.md | 4 + changelog.d/org-inventory.md | 2 + changelog.d/org-settings-audit.md | 2 + scripts/ci-tests.d/org-audit-driver.sh | 2 + scripts/ci-tests.d/org-inventory.sh | 2 + scripts/ci-tests.d/org-settings-audit.sh | 2 + scripts/size-budgets.tsv | 2 +- scripts/test_org_audit_driver.py | 94 ++++++++++ scripts/test_org_inventory.py | 165 +++++++++++++++++ scripts/test_org_settings_audit.py | 111 +++++++++++ 15 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/agentic-delivery/scripts/org_settings_audit.py create mode 100644 .claude/skills/deep-code-review/scripts/org_audit_driver.py create mode 100644 .claude/skills/deep-code-review/scripts/org_inventory.py create mode 100644 changelog.d/org-audit-driver.md create mode 100644 changelog.d/org-audit-tools.md create mode 100644 changelog.d/org-inventory.md create mode 100644 changelog.d/org-settings-audit.md create mode 100644 scripts/ci-tests.d/org-audit-driver.sh create mode 100644 scripts/ci-tests.d/org-inventory.sh create mode 100644 scripts/ci-tests.d/org-settings-audit.sh create mode 100644 scripts/test_org_audit_driver.py create mode 100644 scripts/test_org_inventory.py create mode 100644 scripts/test_org_settings_audit.py diff --git a/.claude/skills/agentic-delivery/scripts/org_settings_audit.py b/.claude/skills/agentic-delivery/scripts/org_settings_audit.py new file mode 100644 index 000000000..c7b045359 --- /dev/null +++ b/.claude/skills/agentic-delivery/scripts/org_settings_audit.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""org_settings_audit.py: read-only audit of GitHub organization settings, then every repository's settings. + +WHY: branch protection, required 2FA, Actions token defaults, installed apps and self-hosted runners are not visible in +code, yet they decide whether a review gate means anything (the CIS GitHub benchmark covers them). Per repository this +reuses `repo_settings_audit.audit` unchanged (branch protection and rulesets, Actions, collaborators, deploy keys, +webhooks, environments, secret scanning and push protection); this file adds the organization-level checks: + 2FA `two_factor_requirement_enabled` on the organization (owner token only) + Actions organization default workflow token has write, workflows can approve reviews, allowed actions = all + apps installed GitHub Apps holding admin or write on repository administration or contents (count) + outside people outside collaborators (count) + self-hosted organization-level self-hosted runners (count) +A 403 or 404, a missing field or an odd reply is a `not checked` row that names the token scope or permission it needs, +never a pass. + +USAGE: org_settings_audit.py ORG [--max-repos N] [--token-env VAR] [--json] +Token: the env var named by --token-env, else `gh auth token`; it reaches curl on stdin only (never argv), as in +org_leak_sweep.py. `GITHUB_API_URL` overrides https://api.github.com (tests point it at a stub). +Output: `{"org": rows, "repos": {"OWNER/NAME": rows}, "summary": {...}}` with `--json`, else one table. +Exit codes: 0 the audit ran (findings do not change it), 2 usage or no token. +Side effects: HTTP GET only; nothing is written or changed. Counts and booleans only: no login, app name or API text. +Limits: first --max-repos repositories (default 200); a longer org says so in a not-checked row. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.parse + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import repo_settings_audit as rsa # noqa: E402 + +API = "https://api.github.com" +ORG = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]*") + + +def curl_get(token): + """get(path, params=None) -> parsed JSON over curl; failure raises OSError('... (HTTP n)') for `rsa.why`.""" + def get(path, params=None): + url = os.environ.get("GITHUB_API_URL", API) + path + ("?" + urllib.parse.urlencode(params) if params else "") + cfg = 'header = "Authorization: Bearer ' + token.replace("\\", "\\\\").replace('"', '\\"') + '"\n' + r = subprocess.run(["curl", "-sS", "-w", "\n%{http_code}", "-H", "Accept: application/vnd.github+json", + "--config", "-", url], input=cfg, capture_output=True, text=True, errors="replace") + body, _, code = r.stdout.rpartition("\n") + if r.returncode or not code.isdigit(): + raise OSError("API error") + if int(code) >= 400: + try: + msg = str(json.loads(body).get("message", "")) + except (ValueError, AttributeError): + msg = "" + raise OSError("%s (HTTP %s)" % (msg, code)) + return json.loads(body) + return get + + +def need(scope): + return ("needs " + scope, "re-run with a token that has " + scope) + + +def org_rows(get, org): + """(check, severity, fix) tuples for the organization itself; any failed read is a not-checked row.""" + base, rows = "/orgs/" + org, [] + o, e = rsa.read(get, base, dict) + if e: + return [rsa.nc("organization", rsa.why(e, need("read access to the organization")))] + tf = o.get("two_factor_requirement_enabled") # absent unless the token is an org owner + if tf is None: + rows.append(rsa.nc("2FA requirement", need("owner access (admin:org)"))) + elif tf is not True: + rows.append(("2FA requirement is off", "high", "enable 'Require two-factor authentication' in the organization security settings")) + adm = "admin:org (fine-grained: Administration read)" + w, e = rsa.read(get, base + "/actions/permissions/workflow", dict, + need=(("default_workflow_permissions",), ("can_approve_pull_request_reviews",))) + if e: + rows.append(rsa.nc("org Actions token", rsa.why(e, need(adm)))) + else: + if w["default_workflow_permissions"] == "write": + rows.append(("org default workflow token can write", "medium", "set the organization default workflow permissions to read")) + if w["can_approve_pull_request_reviews"] is True: + rows.append(("org workflows can approve pull request reviews", "medium", "turn off 'Allow GitHub Actions to create and approve pull requests'")) + a, e = rsa.read(get, base + "/actions/permissions", dict, need=(("enabled_repositories",),)) + if e: + rows.append(rsa.nc("org allowed actions", rsa.why(e, need(adm)))) + elif a.get("allowed_actions") == "all": + rows.append(("org allows all actions", "medium", "restrict allowed actions to GitHub-owned and an allowlist")) + ins, e = rsa.read(get, base + "/installations", dict, need=(("installations",),)) + if e: + rows.append(rsa.nc("installed apps", rsa.why(e, need("admin:read (fine-grained: Administration read)")))) + else: + n = sum(1 for i in ins["installations"] if isinstance(i, dict) and any( + (i.get("permissions") or {}).get(k) in ("write", "admin") for k in ("administration", "contents"))) + rows.append(("%d installed app(s) hold write or admin on administration or contents" % n, "low" if n else "info", + "review each app's permissions in the organization installed apps settings")) + oc, e = rsa.listing(get, base + "/outside_collaborators") + rows.append(rsa.nc("outside collaborators", rsa.why(e, need("admin:org (owner)"))) if e else + ("%d outside collaborator(s)" % len(oc), "low" if oc else "info", "confirm each still needs access")) + rn, e = rsa.listing(get, base + "/actions/runners", key="runners") + rows.append(rsa.nc("self-hosted runners", rsa.why(e, need("admin:org (fine-grained: Self-hosted runners read)"))) if e else + ("%d org self-hosted runner(s)" % len(rn), "medium" if rn else "info", + "keep self-hosted runners off public repositories and use ephemeral runners")) + return rows + + +def audit(org, get, max_repos=200): + """(org rows, {slug: rows}); an unreadable or over-long repo list is a not-checked org row.""" + keys = ("check", "severity", "fix") + rows = [dict(zip(keys, r)) for r in org_rows(get, org)] + repos, e = rsa.listing(get, "/orgs/%s/repos" % org, {"type": "all"}, need=(("full_name",),)) + if e: + rows.append(dict(zip(keys, rsa.nc("repository list", rsa.why(e, need("read access to the organization's repositories")))))) + repos = [] + if len(repos) > max_repos: + rows.append(dict(zip(keys, rsa.nc("repository list", ("capped at %d repos" % max_repos, "raise --max-repos"))))) + rows.sort(key=lambda r: rsa.SEVERITY.index(r["severity"])) + return rows, {r["full_name"]: rsa.audit(r["full_name"], get) for r in repos[:max_repos]} + + +def main(argv=None, get=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("org") + ap.add_argument("--max-repos", type=int, default=200) + ap.add_argument("--token-env") + ap.add_argument("--json", action="store_true") + a = ap.parse_args(argv) + if not ORG.fullmatch(a.org): + print("org_settings_audit: not an organization name", file=sys.stderr) + return 2 + if get is None: + tok = os.environ.get(a.token_env, "") if a.token_env else subprocess.run( + ["gh", "auth", "token"], capture_output=True, text=True).stdout.strip() + if not tok: + print("org_settings_audit: no token (set --token-env VAR or run `gh auth login`)", file=sys.stderr) + return 2 + get = curl_get(tok) + org, repos = audit(a.org, get, a.max_repos) + flat = [r for v in repos.values() for r in v] + summ = {"repos": len(repos), "findings": sum(r["severity"] in rsa.FINDING for r in org + flat), + "not_checked": sum(r["severity"] == "not-checked" for r in org + flat)} + if a.json: + print(json.dumps({"org": org, "repos": repos, "summary": summ}, indent=2)) + else: + print(rsa.render([dict(r, repo=a.org) for r in org] + flat, len(repos))[0]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/deep-code-review/references/tool-hub.md b/.claude/skills/deep-code-review/references/tool-hub.md index 5d096348b..1fe644780 100644 --- a/.claude/skills/deep-code-review/references/tool-hub.md +++ b/.claude/skills/deep-code-review/references/tool-hub.md @@ -65,10 +65,16 @@ pinact (`pinact run`) is the fixer for unpinned-action findings; it needs the Gi | Tool | Covers | Command shape | Flags | Class | |---|---|---|---|---| | `repo_settings_audit.py` | Default-branch protection and rulesets, Actions token settings, collaborator counts, write deploy keys, insecure webhooks, unprotected environments, secret scanning; a 403 or 404 is `not checked`, never clean | `python3 .claude/skills/agentic-delivery/scripts/repo_settings_audit.py OWNER/NAME... [--repos-file FILE] [--json] [--scorecard]` | own script, read-only GETs (`--scorecard` also runs `scorecard --repo=github.com/OWNER/NAME --format=json` per public repo: an `info` row with the aggregate score; a private repo or a missing or failing `scorecard` is a `not checked` row); fields read from the GitHub docs 2026-10-30. OSS options (READMEs read 2026-10-30, not run): `legitify` (`SCM_TOKEN=... legitify analyze --org ORG`; needs an `admin:org` token, fine-grained tokens unsupported) and OpenSSF Scorecard (some Branch-Protection settings need an admin token; `supply_chain_check.sh --scorecard owner/repo` runs it for a public repo) | own; OSS options route-only | +| `org_settings_audit.py` | Read this when asked to audit a GitHub organization, not one repo: required 2FA, org Actions token and allowed actions, installed app permissions, outside collaborators, self-hosted runners, then `repo_settings_audit.py` on every repo; a 403, 404 or missing field is `not checked` naming the scope it needs, never clean | `python3 .claude/skills/agentic-delivery/scripts/org_settings_audit.py ORG [--max-repos N] [--token-env VAR] [--json]` | own script, read-only GETs over curl (token on stdin, never argv) | own | ## Org-wide history leak sweep (own script, not run by `review_checks.sh`) Read this when asked to check every public repo of an org for secrets or internal names in git history, not one working tree. `python3 .claude/skills/deep-code-review/scripts/org_leak_sweep.py --org ORG --banlist .banlist.local.txt [--out PRIVATE_DIR]` lists public repos (curl and `gh auth token`, since `gh` TLS can fail in a sandbox), mirror-clones each, runs `gitleaks detect --redact` over all history and greps added lines against the banlist, then writes `leaks.tsv` (repo, file, commit, rule; never a secret or matched term) to the private out dir. The banlist is never committed (default `.banlist.local.txt`, gitignored); a missing one fails closed. `--local REPO` scans one local repo with no network. Exit 1 means hits or an unscanned repo, 3 means gitleaks missing: both are not clean. +## Org repo inventory (own script, not run by `review_checks.sh`) + +Read this when asked to inventory every repo of one or more orgs, diff it against last time, or prove each repo was actually checked. `python3 .claude/skills/deep-code-review/scripts/org_inventory.py --org ORG [--org ORG2] [--previous OLD.tsv] [--results-dir DIR]` pages the read-only API (`GITHUB_TOKEN` or `gh auth token`) into `inventory-YYYYMMDD.tsv` (repo, visibility, archived, fork, has_wiki, pushed_at), prints added/removed counts vs `--previous`, and prints `NOT CHECKED: org/repo` for any repo with no non-empty `__.*` result newer than `--max-age-days` (default 30; exit 1). Any API, scope or unexpected error, an empty listing, or a count that disagrees with the org totals exits 2 and writes nothing: never read it as zero repos. + +For a full `review_checks.sh` audit of a repo list instead, `python3 .claude/skills/deep-code-review/scripts/org_audit_driver.py --repos FILE --out DIR` (`owner/repo` per line) shallow-clones then unshallows each repo into a fresh temp dir, runs `review_checks.sh --full-history` from the empty tree (never `--tests`, which runs repo code; semgrep forced to the bundled rules), and writes one dated JSON per repo plus `summary-DATE.tsv` listing every NOT CHECKED tool. A clone or scan failure is a FAILED row and exit 1; a NOT CHECKED tool is unchecked surface, not clean. ## Route-only skills (opt-in, never vendored) diff --git a/.claude/skills/deep-code-review/scripts/org_audit_driver.py b/.claude/skills/deep-code-review/scripts/org_audit_driver.py new file mode 100644 index 000000000..b3514ba8a --- /dev/null +++ b/.claude/skills/deep-code-review/scripts/org_audit_driver.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""org_audit_driver.py: run review_checks.sh over a list of repos, one dated result file each, plus a summary. + +Usage: org_audit_driver.py --repos FILE --out DIR [--timeout SECS] +FILE: one `owner/repo` per line ('#' and blank lines skipped). Per repo: shallow clone then `git fetch --unshallow` +into a fresh mkdtemp dir, then `review_checks.sh --base --full-history` (never --tests, which runs repo +code). Semgrep is forced to the bundled rules via REVIEW_SEMGREP_CONFIG, so a repo's own .semgrep.yml is ignored. +Output in --out: `__-.json` (the findings array) and `summary-.tsv` +(repo, status OK|FAILED, findings, NOT CHECKED tools or the failure reason). A clone or scan failure is a FAILED +row, never skipped. Exit 0 = every repo scanned; 1 = any FAILED; 2 = usage or an empty list. NOT CHECKED tools do not fail the +run but are listed per repo: they are unchecked surface, not a clean result. +Env: ORG_AUDIT_BASE_URL (default https://github.com) and ORG_AUDIT_REVIEW_CHECKS (script path) exist for tests. +Side effects: network clones; writes only under --out and its own temp dir, which is removed on exit. +Repo-local scanner config (.gitleaks.toml, .gitleaks-allow, .gitleaksignore, .trivyignore, ...) is deleted in the temp clone +before scanning, so a repo cannot allowlist its own findings; history is still scanned in full. Only root-level +files from UNTRUSTED_CFG are stripped: nested or other-named configs and linter configs (ruff, shellcheck) are NOT. +Every tracked symlink is removed too. Children run with stdin closed, no prompts, no host git config, hooks off, +and are killed (whole process group) on --timeout. Two repos mapping to one output name: the later is FAILED. +""" +import argparse, datetime, json, os, re, signal, subprocess, sys, tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +# Repo-root config an untrusted repo could use to allowlist or silence its own findings; review_checks.sh and the +# tools read them from the tree or from HEAD. Removed in the temp clone (history is still scanned in full). +UNTRUSTED_CFG = [".gitleaks.toml", ".gitleaks-allow", ".gitleaksignore", ".semgrep.yml", ".semgrep", ".semgrepignore", + ".trivyignore", "trivy.yaml", "osv-scanner.toml", ".checkov.yaml", ".checkov.yml", ".hadolint.yaml", + ".hadolint.yml", "zizmor.yml", ".github/zizmor.yml", ".trufflehogignore"] +NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$") + + +# Neutral env for every child: no prompts, no host/system git config, only https/file transports, C locale. +ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0", LC_ALL="C", GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null", + GIT_ALLOW_PROTOCOL="https:file") +GIT = ["git", "-c", "core.hooksPath=/dev/null"] + + +def run(cmd, timeout=None, env=None, inp=None): + """subprocess with stdin closed (or inp), own session, whole process group killed on timeout.""" + p = subprocess.Popen(cmd, stdin=subprocess.PIPE if inp is not None else subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, errors="replace", env=env or ENV, start_new_session=True) + try: + out, err = p.communicate(inp, timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(p.pid, signal.SIGKILL) + p.communicate() + raise + return subprocess.CompletedProcess(cmd, p.returncode, out, err) + + +def audit(repo, tmp, rc_script, base_url, timeout): + """Return (findings_json_text, rows, not_checked) or raise RuntimeError(reason).""" + dest = os.path.join(tmp, "clone") + url = f"{base_url}/{repo}.git" + for cmd in (GIT + ["clone", "--quiet", "--depth", "1", "--", url, dest], GIT + ["-C", dest, "fetch", "--quiet", "--unshallow"]): + r = run(cmd, timeout=timeout) + if r.returncode and "--unshallow" in cmd and "does not make sense" in r.stderr: + continue # already complete (one-commit repo): nothing to unshallow + if r.returncode: + raise RuntimeError(f"{'fetch' if '-C' in cmd else 'clone'} failed (exit {r.returncode})") + # Drop untrusted config and every symlink (a link could make a tool read a host file), commit it in the temp clone. + r = run(GIT + ["-C", dest, "ls-files", "-s", "-z"], timeout=timeout) + if r.returncode: + raise RuntimeError(f"ls-files failed (exit {r.returncode})") + links = [e.split("\t", 1)[1] for e in r.stdout.split("\0") if e.startswith("120000 ")] + r = run(GIT + ["-C", dest, "rm", "-rq", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"], + timeout=timeout, inp="\0".join(UNTRUSTED_CFG + links) + "\0") + if r.returncode: + raise RuntimeError(f"config strip failed (exit {r.returncode})") + r = run(GIT + ["-C", dest, "diff", "--cached", "--quiet"], timeout=timeout) + if r.returncode not in (0, 1): + raise RuntimeError(f"diff --cached failed (exit {r.returncode})") + if r.returncode == 1: # something was removed: commit it so reads from HEAD miss it too + r = run(GIT + ["-C", dest, "-c", "user.name=audit", "-c", "user.email=audit@example.com", "commit", "-qm", "drop repo-local scan config"], timeout=timeout) + if r.returncode: + raise RuntimeError(f"config strip commit failed (exit {r.returncode})") + r = run(GIT + ["-C", dest, "hash-object", "-t", "tree", "/dev/null"], timeout=timeout) + empty = r.stdout.strip() + if r.returncode or not empty: + raise RuntimeError("empty-tree hash failed") + env = dict(ENV, REVIEW_SEMGREP_CONFIG=os.path.join(HERE, "..", "templates", "semgrep-taint")) + r = run(["bash", rc_script, "--repo", dest, "--base", empty, "--full-history"], env=env, timeout=timeout) + try: + rows = json.loads(r.stdout) + assert isinstance(rows, list) + except (ValueError, AssertionError): + raise RuntimeError(f"review_checks gave no JSON (exit {r.returncode})") + if r.returncode: + raise RuntimeError(f"review_checks exit {r.returncode}") + nc = {m.strip() for m in re.findall(r"NOT CHECKED: ([^\n]*)", r.stderr)} + nc |= {f"{x.get('tool')} ({x.get('message')})" for x in rows if x.get("rule") == "not-checked"} + return r.stdout, rows, sorted(nc) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--repos", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--timeout", type=int, default=3600) + a = ap.parse_args() + try: + names = [l.strip() for l in open(a.repos) if l.strip() and not l.lstrip().startswith("#")] + except OSError as e: + print(f"org_audit_driver: {e}", file=sys.stderr) + return 2 + if not names: + print("org_audit_driver: empty repo list", file=sys.stderr) + return 2 + os.makedirs(a.out, exist_ok=True) + day = datetime.date.today().strftime("%Y%m%d") + rc_script = os.environ.get("ORG_AUDIT_REVIEW_CHECKS") or os.path.join(HERE, "review_checks.sh") + base_url = os.environ.get("ORG_AUDIT_BASE_URL", "https://github.com").rstrip("/") + lines, failed, seen = [], 0, set() + for repo in names: + try: + if not NAME.match(repo): + raise RuntimeError("bad repo name (want owner/repo)") + fn = f"{repo.replace('/', '__')}-{day}.json" + if fn.lower() in seen: + raise RuntimeError("output name collision with an earlier repo") + seen.add(fn.lower()) + with tempfile.TemporaryDirectory(prefix="org-audit-") as tmp: # own mkdtemp dir, removed on exit + text, rows, nc = audit(repo, tmp, rc_script, base_url, a.timeout) + with open(os.path.join(a.out, fn), "w") as f: + f.write(text) + lines.append(f"{repo}\tOK\t{len(rows)}\t" + ("; ".join(nc) or "-")) + except (RuntimeError, subprocess.TimeoutExpired, OSError) as e: + failed += 1 + lines.append(f"{repo}\tFAILED\t-\t{str(e).replace(chr(9), ' ').replace(chr(10), ' ')}") + out = "repo\tstatus\tfindings\tnot_checked_or_reason\n" + "\n".join(lines) + "\n" + with open(os.path.join(a.out, f"summary-{day}.tsv"), "w") as f: + f.write(out) + sys.stdout.write(out) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/deep-code-review/scripts/org_inventory.py b/.claude/skills/deep-code-review/scripts/org_inventory.py new file mode 100644 index 000000000..da4567d5e --- /dev/null +++ b/.claude/skills/deep-code-review/scripts/org_inventory.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""org_inventory.py: dated TSV inventory of every repo in one or more GitHub orgs, reconciled against a previous run and a results dir. + +Usage: org_inventory.py --org ORG [--org ORG ...] [--out DIR] [--previous TSV] [--results-dir DIR] [--max-age-days N] + [--token-env VAR] [--api-url URL] + +Writes `/inventory-YYYYMMDD.tsv` with header `orgrepovisibilityarchivedforkhas_wikipushed_at` +(columns: repo is the bare name; archived/fork/has_wiki are true|false). Read-only: GET /orgs/ORG/repos?type=all, paginated. +Token: env var named by --token-env (default GITHUB_TOKEN), else `gh auth token`; none = exit 2. Any HTTP error, bad JSON, +non-list reply (missing scope, 401/403/404, rate limit), unexpected exception, empty listing, more than 1000 pages, or a +listed count that differs from the org totals (GET /orgs/ORG: public_repos, plus total_private_repos when visible) is a +loud exit 2 and no TSV: a failed listing is never zero repos. Pagination follows the Link rel=next header when present, +else stops on the first short page. --api-url must be https (http only for localhost, for tests); the token is never sent +to another host (redirects drop it, a cross-host Link is an error). +--previous TSV (an earlier inventory; must have the exact header and at least one row, else exit 2) prints added and +removed repo counts and names. --results-dir DIR prints `NOT CHECKED: org/repo` for every repo with no non-empty file +`__.*` in DIR modified within --max-age-days (default 30), and exits 1 if any exist (0 = every repo has a fresh +result). Exit codes: 0 ok, 1 unchecked repos, 2 usage/API error. +Side effects: network GETs to the API only; writes only the inventory TSV under --out (default $TMPDIR/org-inventory). +""" +import argparse, csv, datetime, glob, json, os, re, subprocess, sys, time, urllib.error, urllib.parse, urllib.request + +MAX_PAGES = 1000 +COLS = ["org", "repo", "visibility", "archived", "fork", "has_wiki", "pushed_at"] + + +def fail(msg): + print(f"org_inventory: {msg}", file=sys.stderr) + raise SystemExit(2) + + +class NoAuthRedirect(urllib.request.HTTPRedirectHandler): + """Drops the Authorization header when a redirect leaves the original host.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + new = super().redirect_request(req, fp, code, msg, headers, newurl) + if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc: + new.headers = {k: v for k, v in new.headers.items() if k.lower() != "authorization"} + new.unredirected_hdrs.pop("Authorization", None) + return new + + +OPEN = urllib.request.build_opener(NoAuthRedirect).open + + +def get_json(url, token): + """GET url -> (parsed JSON, Link header). Every failure is a loud exit 2.""" + where = url.split("?")[0] + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}"}) + try: + with OPEN(req, timeout=30) as r: + return json.load(r), r.headers.get("Link", "") + except urllib.error.HTTPError as e: + fail(f"HTTP {e.code} on {where} (missing scope or access?); inventory NOT written") + except Exception as e: # URLError, IncompleteRead, bad JSON, timeout: never a traceback, never zero repos + fail(f"{type(e).__name__} on {where}; inventory NOT written") + + +def next_link(link): + m = re.search(r'<([^>]+)>\s*;\s*rel="next"', link) + return m.group(1) if m else None + + +def list_org(api, org, token): + info, _ = get_json(f"{api}/orgs/{org}", token) + if not isinstance(info, dict) or not isinstance(info.get("public_repos"), int): + fail(f"{org}: org totals missing in API reply; inventory NOT written") + rows, url, host = [], f"{api}/orgs/{org}/repos?type=all&per_page=100&page=1", urllib.parse.urlsplit(api).netloc + for page in range(1, MAX_PAGES + 1): + batch, link = get_json(url, token) + if not isinstance(batch, list): + fail(f"unexpected reply from {url.split('?')[0]}; inventory NOT written") + try: + for x in batch: + rows.append([org, x["name"], x.get("visibility") or ("private" if x.get("private") else "public"), + str(bool(x.get("archived"))).lower(), str(bool(x.get("fork"))).lower(), + str(bool(x.get("has_wiki"))).lower(), x.get("pushed_at") or ""]) + except (KeyError, TypeError, AttributeError): + fail(f"malformed repo entry on {url.split('?')[0]}; inventory NOT written") + nxt = next_link(link) if link else (None if len(batch) < 100 else f"{api}/orgs/{org}/repos?type=all&per_page=100&page={page + 1}") + if nxt is None: + break + if urllib.parse.urlsplit(nxt).netloc != host: + fail(f"Link header points off-host ({urllib.parse.urlsplit(nxt).netloc}); inventory NOT written") + url = nxt + else: + fail(f"{org}: more than {MAX_PAGES} pages; inventory NOT written") + pub = sum(r[2] == "public" for r in rows) + bad = pub != info["public_repos"] + if isinstance(info.get("total_private_repos"), int): + bad = bad or len(rows) - pub != info["total_private_repos"] + if not rows or bad: + fail(f"{org}: listed {len(rows)} repos ({pub} public) but org totals say {info['public_repos']} public" + f" + {info.get('total_private_repos', 'unknown')} private; token scope too narrow? inventory NOT written") + return rows + + +def fresh(results_dir, org, repo, max_age_days): + cutoff = time.time() - max_age_days * 86400 + pat = os.path.join(glob.escape(results_dir), glob.escape(f"{org}__{repo}") + ".*") + return any(os.path.getsize(p) > 0 and os.path.getmtime(p) >= cutoff for p in glob.glob(pat)) + + +def _main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--org", action="append", required=True) + ap.add_argument("--out", default=os.path.join(os.environ.get("TMPDIR", "/tmp"), "org-inventory")) + ap.add_argument("--previous") + ap.add_argument("--results-dir") + ap.add_argument("--max-age-days", type=float, default=30) + ap.add_argument("--token-env", default="GITHUB_TOKEN") + ap.add_argument("--api-url", default="https://api.github.com") + a = ap.parse_args() + token = os.environ.get(a.token_env, "") + if not token: + try: + token = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True).stdout.strip() + except OSError: + pass + if not token: + fail(f"no token: set {a.token_env} or run `gh auth login`") + prev = None + if a.previous: + if not os.path.isfile(a.previous): + fail(f"--previous {a.previous} not found") + with open(a.previous, newline="") as fh: + rd = csv.DictReader(fh, delimiter="\t") + prev = {(r["org"], r["repo"]) for r in rd} + cols = rd.fieldnames + if cols != COLS or not prev or any(not o or not r for o, r in prev): + fail(f"--previous {a.previous} is malformed or has no repos (want header {COLS} and at least one row)") + if a.results_dir and not os.path.isdir(a.results_dir): + fail(f"--results-dir {a.results_dir} not a directory") + api = a.api_url.rstrip("/") + u = urllib.parse.urlsplit(api) + if u.scheme != "https" and not (u.scheme == "http" and u.hostname in ("localhost", "127.0.0.1")): + fail(f"--api-url must be https (http only for localhost): {api}") + rows = [] + for org in a.org: + rows += list_org(api, org, token) + os.makedirs(a.out, mode=0o700, exist_ok=True) + path = os.path.join(a.out, f"inventory-{datetime.date.today():%Y%m%d}.tsv") + with open(path, "w", newline="") as fh: + w = csv.writer(fh, delimiter="\t", lineterminator="\n") + w.writerow(COLS) + w.writerows(rows) + print(f"org_inventory: {len(rows)} repos, inventory {path}") + if prev is not None: + now = {(r[0], r[1]) for r in rows} + added, removed = sorted(now - prev), sorted(prev - now) + print(f"reconcile: previous {len(prev)}, now {len(now)}, added {len(added)}, removed {len(removed)}") + for tag, items in (("added", added), ("removed", removed)): + for o, r in items: + print(f"{tag}: {o}/{r}") + bad = 0 + if a.results_dir: + for r in rows: + if not fresh(a.results_dir, r[0], r[1], a.max_age_days): + print(f"NOT CHECKED: {r[0]}/{r[1]}") + bad += 1 + return 1 if bad else 0 + + +def main(): + try: + return _main() + except (SystemExit, KeyboardInterrupt): + raise + except Exception as e: # fail closed: any unexpected error is exit 2, never a traceback or a partial TSV + fail(f"unexpected {type(e).__name__}: {e}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/changelog.d/org-audit-driver.md b/changelog.d/org-audit-driver.md new file mode 100644 index 000000000..87a878faa --- /dev/null +++ b/changelog.d/org-audit-driver.md @@ -0,0 +1,2 @@ +### Added +- org_audit_driver.py runs review_checks.sh (full history, empty-tree base, never --tests, bundled semgrep rules) over an owner/repo list, one dated result per repo plus a summary of every NOT CHECKED tool; a clone or scan failure is a FAILED row and a nonzero exit. diff --git a/changelog.d/org-audit-tools.md b/changelog.d/org-audit-tools.md new file mode 100644 index 000000000..9d2c61a67 --- /dev/null +++ b/changelog.d/org-audit-tools.md @@ -0,0 +1,4 @@ +### Added +- Org audit tool route rows (org_settings_audit.py, org_audit_driver.py, org_inventory.py) in references/tool-hub.md. + +size-budget-raise: .claude/skills/deep-code-review/references/tool-hub.md 17473→19414 org settings audit, org audit driver and org inventory route rows diff --git a/changelog.d/org-inventory.md b/changelog.d/org-inventory.md new file mode 100644 index 000000000..c689c3f67 --- /dev/null +++ b/changelog.d/org-inventory.md @@ -0,0 +1,2 @@ +### Added +- org_inventory.py writes a dated TSV inventory of every repo in one or more orgs from the read-only API, reconciles added and removed repos against a previous run, and marks any repo without a fresh result file as NOT CHECKED; an API or scope error exits 2 and never yields zero repos. diff --git a/changelog.d/org-settings-audit.md b/changelog.d/org-settings-audit.md new file mode 100644 index 000000000..0a9c1648e --- /dev/null +++ b/changelog.d/org-settings-audit.md @@ -0,0 +1,2 @@ +### Added +- org_settings_audit.py audits a GitHub organization (2FA requirement, Actions token defaults, installed apps, outside collaborators, self-hosted runners) and every repo via repo_settings_audit; read-only GETs, and a 403 or missing field is a NOT CHECKED row naming the scope needed. diff --git a/scripts/ci-tests.d/org-audit-driver.sh b/scripts/ci-tests.d/org-audit-driver.sh new file mode 100644 index 000000000..63d1220d5 --- /dev/null +++ b/scripts/ci-tests.d/org-audit-driver.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_org_audit_driver.py with CI. +python3 scripts/test_org_audit_driver.py diff --git a/scripts/ci-tests.d/org-inventory.sh b/scripts/ci-tests.d/org-inventory.sh new file mode 100644 index 000000000..93ac2a527 --- /dev/null +++ b/scripts/ci-tests.d/org-inventory.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_org_inventory.py with CI. +python3 scripts/test_org_inventory.py diff --git a/scripts/ci-tests.d/org-settings-audit.sh b/scripts/ci-tests.d/org-settings-audit.sh new file mode 100644 index 000000000..c31d8e7e0 --- /dev/null +++ b/scripts/ci-tests.d/org-settings-audit.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_org_settings_audit.py with CI (see the "Per-PR tests" step in ci.yml). +python3 scripts/test_org_settings_audit.py diff --git a/scripts/size-budgets.tsv b/scripts/size-budgets.tsv index 099b5a4bd..f4cd89bed 100644 --- a/scripts/size-budgets.tsv +++ b/scripts/size-budgets.tsv @@ -234,7 +234,7 @@ README.md 16118 .claude/skills/deep-code-review/references/small-diff-read-budget.md 526 .claude/skills/deep-code-review/references/app-pentest.md 8135 .claude/skills/deep-code-review/references/supply-chain-check.md 3646 -.claude/skills/deep-code-review/references/tool-hub.md 17473 +.claude/skills/deep-code-review/references/tool-hub.md 19414 .claude/skills/deep-code-review/references/desktop-native-appsec.md 4724 .claude/skills/deep-code-review/references/dynamic-probe.md 2095 .claude/skills/agentic-delivery/references/flow-guards.md 3171 diff --git a/scripts/test_org_audit_driver.py b/scripts/test_org_audit_driver.py new file mode 100644 index 000000000..1d1cf7383 --- /dev/null +++ b/scripts/test_org_audit_driver.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Tests: org_audit_driver.py against local file:// fixture repos and a stub review_checks.sh. Checks: a good repo is +scanned from a full (unshallowed) clone with --full-history, an empty-tree --base and no --tests, bundled semgrep rules +forced; NOT CHECKED tools are listed; a missing repo and a bad name are FAILED rows with exit 1; a repo-local +.gitleaks.toml/.gitleaksignore and every symlink are gone from the scanned tree and HEAD; a one-commit repo works; a +failing config-strip commit fails closed; a duplicate output name is FAILED; an empty list exits 2; the temp clone is +removed. No network; every name is fictional.""" +import glob, os, shutil, subprocess, sys, tempfile + +D = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".claude", "skills", "deep-code-review", "scripts", "org_audit_driver.py") +STUB = """#!/usr/bin/env bash +{ echo "$*"; echo "$REVIEW_SEMGREP_CONFIG"; git -C "$2" rev-list --count HEAD; echo "$2" + ls -A "$2" | grep -c "^.gitleaks"; git -C "$2" ls-tree HEAD --name-only | grep -c "^.gitleaks" + find "$2" -name .git -prune -o -type l -print | wc -l | tr -d ' '; } >"$STUB_LOG" +echo "NOT CHECKED: gitleaks (install: brew install gitleaks)" >&2 +echo '[{"file":"a.txt","line":1,"rule":"r","message":"m","text":"r: m","tool":"t"}]' +""" +N = [0] + + +def sh(*c, **kw): + return subprocess.run(c, capture_output=True, text=True, **kw) + + +def check(ok, msg): + N[0] += 1 + if not ok: + sys.exit("FAIL: " + msg) + + +def drive(t, names, env, tag): + repos, out = os.path.join(t, f"repos-{tag}.txt"), os.path.join(t, f"out-{tag}") + open(repos, "w").write(names) + r = sh(sys.executable, D, "--repos", repos, "--out", out, env=env) + return r, out, {l.split("\t")[0]: l.split("\t") for l in r.stdout.splitlines()[1:]} + + +def mk(t, name, commits, link=False): + src, bare = os.path.join(t, "src-" + name), os.path.join(t, "acme", name + ".git") + g = ["git", "-c", "user.name=Jane Smith", "-c", "user.email=jane@example.com"] + sh("git", "init", "-q", src) + for i in range(commits): + open(os.path.join(src, "a.txt"), "w").write(str(i)) + open(os.path.join(src, ".gitleaks.toml"), "w").write("[allowlist]\npaths=['a.txt']\n") + open(os.path.join(src, ".gitleaksignore"), "w").write("x\n") + if link and not os.path.islink(os.path.join(src, "lnk")): + os.symlink("/etc/hosts", os.path.join(src, "lnk")) + sh(*g, "-C", src, "add", "-A"), sh(*g, "-C", src, "commit", "-qm", f"c{i}") + os.makedirs(os.path.dirname(bare), exist_ok=True) + sh("git", "clone", "-q", "--bare", src, bare) + + +def main(): + with tempfile.TemporaryDirectory() as t: + mk(t, "widgets", 2, link=True) + mk(t, "solo", 1) + stub, log = os.path.join(t, "rc.sh"), os.path.join(t, "log") + open(stub, "w").write(STUB) + env = dict(os.environ, ORG_AUDIT_BASE_URL="file://" + t, ORG_AUDIT_REVIEW_CHECKS=stub, STUB_LOG=log) + r, out, rows = drive(t, "# c\nacme/widgets\nacme/missing\nbad name\n", env, "a") + check(r.returncode == 1, f"exit 1 expected on failed rows, got {r.returncode}: {r.stderr}") + check(rows["acme/widgets"][1:3] == ["OK", "1"], f"good row: {rows['acme/widgets']}") + check("gitleaks" in rows["acme/widgets"][3], "NOT CHECKED tool not listed") + check(rows["acme/missing"][1] == "FAILED" and rows["bad name"][1] == "FAILED", "failed rows missing") + flags, sg, n, clone, wt, hd, ln = open(log).read().splitlines() + check("--full-history" in flags and "--tests" not in flags, f"flags: {flags}") + check("--base 4b825dc642cb6eb9a060e54bf8d69288fbee4904" in flags, "empty-tree base missing") + check(sg.endswith("templates/semgrep-taint"), f"semgrep config not forced: {sg}") + check(n == "3", f"want 2 history commits + 1 strip commit: {n}") + check(wt == "0" and hd == "0", f"repo-local gitleaks config still present: tree {wt}, HEAD {hd}") + check(ln == "0", f"symlink left in scanned tree: {ln}") + check(not os.path.exists(clone), "temp clone not cleaned") + check(len(glob.glob(out + "/acme__widgets-*.json")) == 1 and glob.glob(out + "/summary-*.tsv"), "result files") + # one-commit repo (nothing to unshallow) scans fine + r, _, rows = drive(t, "acme/solo\n", env, "b") + check(r.returncode == 0 and rows["acme/solo"][1] == "OK", f"one-commit repo: {r.stdout} {r.stderr}") + # duplicate output name is FAILED, first still OK + r, _, _ = drive(t, "acme/solo\nacme/solo\n", env, "c") + check(r.returncode == 1 and r.stdout.count("collision") == 1, f"collision: {r.stdout}") + # empty / comment-only list exits 2 + r, _, _ = drive(t, "# nothing\n\n", env, "d") + check(r.returncode == 2, f"empty list exit {r.returncode}") + # a failing strip commit fails closed + wrap = os.path.join(t, "bin") + os.makedirs(wrap) + real = shutil.which("git") + open(os.path.join(wrap, "git"), "w").write(f'#!/bin/sh\ncase " $* " in *" commit "*) exit 1;; esac\nexec {real} "$@"\n') + os.chmod(os.path.join(wrap, "git"), 0o755) + r, _, rows = drive(t, "acme/solo\n", dict(env, PATH=wrap + ":" + os.environ["PATH"]), "e") + check(r.returncode == 1 and rows["acme/solo"][1] == "FAILED" and "commit" in rows["acme/solo"][3], f"strip commit failure: {r.stdout}") + print(f"Tests: {N[0]}/{N[0]}") + + +main() diff --git a/scripts/test_org_inventory.py b/scripts/test_org_inventory.py new file mode 100644 index 000000000..c576e4d81 --- /dev/null +++ b/scripts/test_org_inventory.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Tests: org_inventory.py against a fake in-process GitHub API (no network, fictional acme-org data): pagination, Link header paging, TSV columns, --previous reconcile and malformed-previous exit 2, --results-dir NOT CHECKED (missing, stale, empty, wrong-name files), count mismatch, empty listing, HTTP and unexpected errors all exit 2 with no TSV, https-only --api-url, page cap, off-host Link, and redirect auth stripping.""" +import contextlib +import http.client +import importlib.util +import io +import json +import os +import sys +import tempfile +import time +import types +import urllib.error +import urllib.request + +S = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".claude", "skills", "deep-code-review", "scripts", "org_inventory.py") +REPOS = [{"name": f"repo-{i:03d}", "visibility": "public", "archived": i == 1, "fork": i == 2, "has_wiki": True, + "pushed_at": "2026-01-01T00:00:00Z"} for i in range(103)] + + +class Resp(io.BytesIO): + headers = {} + + def __enter__(self): + return self + + def __exit__(self, *a): + self.close() + + +def resp(obj, link=""): + r = Resp(json.dumps(obj).encode()) + r.headers = {"Link": link} if link else {} + return r + + +def fake_open(req, timeout=None): + """Stands in for the GitHub API: no socket is ever opened.""" + url = req.full_url + org, listing = url.split("/")[4].split("?")[0], "/repos" in url + if org == "denied-org": + raise urllib.error.HTTPError(url, 403, "Forbidden", {}, None) + if org == "boom-org": + raise http.client.IncompleteRead(b"x") + totals = {"public_repos": {"empty-org": 0, "mismatch-org": 105}.get(org, 103)} + if org == "priv-org": + totals["total_private_repos"] = 2 # visible to the token, but none listed + if not listing: + return resp(totals) + if org == "weird-org": + return resp({"message": "nope"}) + if org == "empty-org": + return resp([]) + if org == "keyerr-org": + return resp([{"nope": 1}]) + if org == "link-org": # Link header drives paging: page 1 is short (60) yet has a next link + if url.endswith("&page=1"): + return resp(REPOS[:60], '; rel="next"') + return resp(REPOS[60:]) + if org == "offhost-org": + return resp(REPOS[:100], '; rel="next"') + if org == "loop-org": + return resp(REPOS[:100], '; rel="next"') + return resp(REPOS[:100] if url.endswith("&page=1") else REPOS[100:]) + + +def run(*args, token="tok"): + """Run org_inventory.main() in-process with the fake API; returns (exit code, stdout, stderr).""" + spec = importlib.util.spec_from_file_location("org_inventory", S) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + m.OPEN = fake_open + m.MAX_PAGES = 5 + m.subprocess.run = lambda *a, **k: types.SimpleNamespace(stdout="") # `gh auth token` yields nothing + os.environ.pop("GITHUB_TOKEN", None) + if token: + os.environ["GITHUB_TOKEN"] = token + out, err, code = io.StringIO(), io.StringIO(), 0 + sys.argv = ["org_inventory.py", *args] + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = m.main() + except SystemExit as e: + code = e.code + return code, out.getvalue(), err.getvalue() + + +def main(): + n = 0 + + def check(cond, msg): + nonlocal n + n += 1 + if not cond: + sys.exit(f"FAIL: {msg}") + + with tempfile.TemporaryDirectory() as t: + out, res = os.path.join(t, "out"), os.path.join(t, "res") + os.makedirs(res) + c, so, se = run("--org", "acme-org", "--out", out) + check(c == 0 and "103 repos" in so, f"listing: {c} {so} {se}") + tsv = os.path.join(out, os.listdir(out)[0]) + lines = open(tsv).read().splitlines() + check(lines[0].split("\t") == ["org", "repo", "visibility", "archived", "fork", "has_wiki", "pushed_at"], "header") + check(len(lines) == 104 and lines[2].split("\t")[3] == "true" and lines[3].split("\t")[4] == "true", "rows/pagination") + prev = os.path.join(t, "prev.tsv") + with open(prev, "w") as fh: + fh.write("\n".join(lines[:-1] + ["org\trepo", "acme-org\tgone-repo"][1:]) + "\n") + c, so, se = run("--org", "acme-org", "--out", out, "--previous", prev) + check("added 1, removed 1" in so and "added: acme-org/repo-102" in so + and "removed: acme-org/gone-repo" in so, f"reconcile: {so}") + for i in range(102): + with open(os.path.join(res, f"acme-org__repo-{i:03d}.json"), "w") as fh: + fh.write("{}") + old = os.path.join(res, "acme-org__repo-005.json") + os.utime(old, (time.time() - 40 * 86400,) * 2) + c, so, se = run("--org", "acme-org", "--out", out, "--results-dir", res) + check(c == 1 and so.count("NOT CHECKED") == 2 and "NOT CHECKED: acme-org/repo-005" in so + and "NOT CHECKED: acme-org/repo-102" in so, f"not checked: {so}") + c, so, se = run("--org", "acme-org", "--out", out, "--results-dir", res, "--max-age-days", "60") + check(so.count("NOT CHECKED") == 1, "max-age-days") + for org in ("denied-org", "weird-org", "empty-org", "mismatch-org", "priv-org", "keyerr-org", "boom-org", "offhost-org", "loop-org"): + o2 = os.path.join(t, org) + c, so, se = run("--org", org, "--out", o2) + check(c == 2 and not os.path.exists(o2), f"{org} must exit 2, no TSV: {c}") + c, so, se = run("--org", "acme-org", "--org", "denied-org", "--out", os.path.join(t, "o3")) + check(c == 2 and not os.path.exists(os.path.join(t, "o3")), "partial org failure") + o4 = os.path.join(t, "o4") + c, so, se = run("--org", "link-org", "--out", o4) + check(c == 0 and "103 repos" in so, f"Link header paging: {c} {so} {se}") + for bad in ("http://api.github.com", "ftp://x.example.com"): + c, so, se = run("--org", "acme-org", "--out", o4, "--api-url", bad) + check(c == 2 and "https" in se, f"--api-url {bad} must exit 2") + c, so, se = run("--org", "acme-org", "--out", o4, "--api-url", "http://localhost:1") + check(c == 0, "http localhost allowed") + for name, body in (("hdr", "org\trepo\n"), ("junk", "hello\n"), ("empty", "")): + p = os.path.join(t, name + ".tsv") + with open(p, "w") as fh: + fh.write(body if name != "hdr" else "\t".join(["org", "repo", "visibility", "archived", "fork", "has_wiki", "pushed_at"]) + "\n") + c, so, se = run("--org", "acme-org", "--out", o4, "--previous", p) + check(c == 2 and "malformed" in se, f"--previous {name} must exit 2: {c} {se}") + with open(os.path.join(res, "acme-org__repo-102.json"), "w"): + pass # zero-byte result is not a result + c, so, se = run("--org", "acme-org", "--out", o4, "--results-dir", res) + check("NOT CHECKED: acme-org/repo-102" in so, "empty result file must not count") + with open(os.path.join(res, "repo-102.json"), "w") as fh: + fh.write("{}") # bare repo name (old naming) must not match either + c, so, se = run("--org", "acme-org", "--out", o4, "--results-dir", res) + check("NOT CHECKED: acme-org/repo-102" in so, "only org__repo.* matches") + spec = importlib.util.spec_from_file_location("oi", S) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + req = urllib.request.Request("https://api.github.com/x", headers={"Authorization": "Bearer t"}) + h = m.NoAuthRedirect() + off = h.redirect_request(req, None, 302, "Found", {}, "https://evil.example.com/y") + same = h.redirect_request(req, None, 302, "Found", {}, "https://api.github.com/z") + check(not any(k.lower() == "authorization" for k in off.headers) and not off.unredirected_hdrs.get("Authorization"), "token must not follow an off-host redirect") + check(any(k.lower() == "authorization" for k in same.headers), "same-host redirect keeps auth") + c, so, se = run("--org", "acme-org", "--out", out, token="") + check(c == 2 and "no token" in se, "missing token") + print(f"Tests: {n}/{n}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_org_settings_audit.py b/scripts/test_org_settings_audit.py new file mode 100644 index 000000000..0fa541c13 --- /dev/null +++ b/scripts/test_org_settings_audit.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Offline tests for org_settings_audit.py: org checks pass, fail and 403 (never a pass), per-repo reuse, and the curl +getter end to end against a stub `curl` on PATH (token on stdin only, never argv). Fictional acme org, no network.""" +import contextlib +import io +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / ".claude/skills/agentic-delivery/scripts")) +import org_settings_audit as osa # noqa: E402 + +O = "/orgs/acme" +FORBIDDEN = OSError("Resource not accessible by personal access token (HTTP 403)") +GOOD = {O: {"two_factor_requirement_enabled": True}, + O + "/actions/permissions/workflow": {"default_workflow_permissions": "read", "can_approve_pull_request_reviews": False}, + O + "/actions/permissions": {"enabled_repositories": "all", "allowed_actions": "selected"}, + O + "/installations": {"installations": [{"permissions": {"issues": "write"}}]}, + O + "/outside_collaborators": [], O + "/actions/runners": {"runners": []}, O + "/repos": []} + + +def fake(**over): + d = dict(GOOD, **over) + + def get(path, params=None): + v = d.get(path, FORBIDDEN) + if isinstance(v, Exception): + raise v + return v + return get + + +def checks(rows): + return {r["check"]: r["severity"] for r in rows} + + +def deny(path, params=None): + raise FORBIDDEN + + +class T(unittest.TestCase): + def test_pass(self): + org, repos = osa.audit("acme", fake()) + self.assertEqual({r["severity"] for r in org}, {"info"}) + self.assertEqual(repos, {}) + + def test_fail(self): + bad = {O: {"two_factor_requirement_enabled": False}, + O + "/actions/permissions/workflow": {"default_workflow_permissions": "write", "can_approve_pull_request_reviews": True}, + O + "/actions/permissions": {"enabled_repositories": "all", "allowed_actions": "all"}, + O + "/outside_collaborators": [{"login": "jane"}], O + "/actions/runners": {"runners": [{"id": 1}]}} + org, _ = osa.audit("acme", fake(**bad)) + c = checks(org) + self.assertEqual(c["2FA requirement is off"], "high") + self.assertEqual(c["org default workflow token can write"], "medium") + self.assertEqual(c["org allows all actions"], "medium") + self.assertEqual(c["1 outside collaborator(s)"], "low") + self.assertEqual(c["1 org self-hosted runner(s)"], "medium") + self.assertEqual(org[0]["severity"], "high") + + def test_403_is_not_checked_with_scope(self): + org, _ = osa.audit("acme", deny) + self.assertEqual(len(org), 2) # org unreadable + repo list unreadable + self.assertTrue(all(r["severity"] == "not-checked" and "token that has" in r["fix"] for r in org)) + org, _ = osa.audit("acme", fake(**{O: {"login": "acme"}, O + "/actions/runners": FORBIDDEN})) + c = checks(org) + self.assertEqual(c["2FA requirement: not checked (needs owner access (admin:org))"], "not-checked") + self.assertIn("self-hosted runners: not checked (needs admin:org (fine-grained: Self-hosted runners read))", c) + self.assertFalse(any(r["severity"] == "info" and "2FA" in r["check"] for r in org)) + + def test_repos_reuse_and_cap(self): + g = fake(**{O + "/repos": [{"full_name": "acme/a"}, {"full_name": "acme/b"}]}) + org, repos = osa.audit("acme", g, max_repos=1) + self.assertEqual(list(repos), ["acme/a"]) + self.assertTrue(any("capped at 1" in r["check"] for r in org)) + self.assertEqual(repos["acme/a"][0]["severity"], "not-checked") # repo read 403, not a pass + + def test_cli(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(osa.main(["acme", "--json"], get=fake()), 0) + self.assertEqual(json.loads(out.getvalue())["summary"], {"repos": 0, "findings": 0, "not_checked": 0}) + self.assertEqual(osa.main(["bad/org"], get=fake()), 2) + + def test_curl_stub(self): + with tempfile.TemporaryDirectory() as t: + stub = Path(t, "curl") + stub.write_text('#!/bin/sh\necho "$@" >> "$STUB_LOG"\ncat >> "$STUB_LOG"\n' + 'printf \'{"message":"Resource not accessible"}\\n403\'\n') + stub.chmod(stub.stat().st_mode | stat.S_IEXEC) + env = {"PATH": t + os.pathsep + os.environ["PATH"], "STUB_LOG": str(Path(t, "log")), "TOK": "ghp_fake0123456789"} + out = io.StringIO() + with mock.patch.dict(os.environ, env), contextlib.redirect_stdout(out): + self.assertEqual(osa.main(["acme", "--token-env", "TOK", "--json"]), 0) + log = Path(t, "log").read_text() + argv_lines = [ln for ln in log.splitlines() if ln.startswith("-sS")] + self.assertTrue(argv_lines and not any("ghp_fake" in ln for ln in argv_lines)) + self.assertIn("Authorization: Bearer ghp_fake0123456789", log) # arrived on stdin + summ = json.loads(out.getvalue())["summary"] + self.assertEqual(summ["findings"], 0) + self.assertGreater(summ["not_checked"], 0) + + +if __name__ == "__main__": + unittest.main() From 2843e060521f8fc82a0e4fe6b5ff1c6c6080f012 Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:45:14 +0100 Subject: [PATCH 2/4] org_audit_driver: real review_checks base, dot-leading repo names, stderr in reasons Fix the stderr-reason assertion in the previous commit (its Tests: 21/21 count was wrong; the suite has 20 checks). The driver now calls review_checks.sh with --full --full-history, allows repo names such as .github (still rejects . and ..), and appends the last stderr line of a failing review_checks to the FAILED reason. Tests: 20/20 Co-Authored-By: Claude Opus 5.5 --- .../scripts/org_audit_driver.py | 18 ++++++++---------- scripts/test_org_audit_driver.py | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.claude/skills/deep-code-review/scripts/org_audit_driver.py b/.claude/skills/deep-code-review/scripts/org_audit_driver.py index b3514ba8a..a46683fb5 100644 --- a/.claude/skills/deep-code-review/scripts/org_audit_driver.py +++ b/.claude/skills/deep-code-review/scripts/org_audit_driver.py @@ -3,11 +3,11 @@ Usage: org_audit_driver.py --repos FILE --out DIR [--timeout SECS] FILE: one `owner/repo` per line ('#' and blank lines skipped). Per repo: shallow clone then `git fetch --unshallow` -into a fresh mkdtemp dir, then `review_checks.sh --base --full-history` (never --tests, which runs repo +into a fresh mkdtemp dir, then `review_checks.sh --full --full-history` (never --tests, which runs repo code). Semgrep is forced to the bundled rules via REVIEW_SEMGREP_CONFIG, so a repo's own .semgrep.yml is ignored. Output in --out: `__-.json` (the findings array) and `summary-.tsv` (repo, status OK|FAILED, findings, NOT CHECKED tools or the failure reason). A clone or scan failure is a FAILED -row, never skipped. Exit 0 = every repo scanned; 1 = any FAILED; 2 = usage or an empty list. NOT CHECKED tools do not fail the +row (a review_checks failure appends its last stderr line, 200 chars max), never skipped. Exit 0 = every repo scanned; 1 = any FAILED; 2 = usage or an empty list. NOT CHECKED tools do not fail the run but are listed per repo: they are unchecked surface, not a clean result. Env: ORG_AUDIT_BASE_URL (default https://github.com) and ORG_AUDIT_REVIEW_CHECKS (script path) exist for tests. Side effects: network clones; writes only under --out and its own temp dir, which is removed on exit. @@ -25,7 +25,7 @@ UNTRUSTED_CFG = [".gitleaks.toml", ".gitleaks-allow", ".gitleaksignore", ".semgrep.yml", ".semgrep", ".semgrepignore", ".trivyignore", "trivy.yaml", "osv-scanner.toml", ".checkov.yaml", ".checkov.yml", ".hadolint.yaml", ".hadolint.yml", "zizmor.yml", ".github/zizmor.yml", ".trufflehogignore"] -NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$") +NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/(?!\.{1,2}$)[A-Za-z0-9._-]+$") # repo may lead with a dot, never be . or .. # Neutral env for every child: no prompts, no host/system git config, only https/file transports, C locale. @@ -73,19 +73,17 @@ def audit(repo, tmp, rc_script, base_url, timeout): r = run(GIT + ["-C", dest, "-c", "user.name=audit", "-c", "user.email=audit@example.com", "commit", "-qm", "drop repo-local scan config"], timeout=timeout) if r.returncode: raise RuntimeError(f"config strip commit failed (exit {r.returncode})") - r = run(GIT + ["-C", dest, "hash-object", "-t", "tree", "/dev/null"], timeout=timeout) - empty = r.stdout.strip() - if r.returncode or not empty: - raise RuntimeError("empty-tree hash failed") env = dict(ENV, REVIEW_SEMGREP_CONFIG=os.path.join(HERE, "..", "templates", "semgrep-taint")) - r = run(["bash", rc_script, "--repo", dest, "--base", empty, "--full-history"], env=env, timeout=timeout) + r = run(["bash", rc_script, "--repo", dest, "--full", "--full-history"], env=env, timeout=timeout) + last = next((l.strip() for l in reversed(r.stderr.splitlines()) if l.strip()), "") + why = f": {last.replace(chr(9), ' ')[:200]}" if last else "" try: rows = json.loads(r.stdout) assert isinstance(rows, list) except (ValueError, AssertionError): - raise RuntimeError(f"review_checks gave no JSON (exit {r.returncode})") + raise RuntimeError(f"review_checks gave no JSON (exit {r.returncode}){why}") if r.returncode: - raise RuntimeError(f"review_checks exit {r.returncode}") + raise RuntimeError(f"review_checks exit {r.returncode}{why}") nc = {m.strip() for m in re.findall(r"NOT CHECKED: ([^\n]*)", r.stderr)} nc |= {f"{x.get('tool')} ({x.get('message')})" for x in rows if x.get("rule") == "not-checked"} return r.stdout, rows, sorted(nc) diff --git a/scripts/test_org_audit_driver.py b/scripts/test_org_audit_driver.py index 1d1cf7383..1c5ef808c 100644 --- a/scripts/test_org_audit_driver.py +++ b/scripts/test_org_audit_driver.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Tests: org_audit_driver.py against local file:// fixture repos and a stub review_checks.sh. Checks: a good repo is -scanned from a full (unshallowed) clone with --full-history, an empty-tree --base and no --tests, bundled semgrep rules +scanned from a full (unshallowed) clone with --full-history, --full and no --tests, bundled semgrep rules forced; NOT CHECKED tools are listed; a missing repo and a bad name are FAILED rows with exit 1; a repo-local .gitleaks.toml/.gitleaksignore and every symlink are gone from the scanned tree and HEAD; a one-commit repo works; a failing config-strip commit fails closed; a duplicate output name is FAILED; an empty list exits 2; the temp clone is -removed. No network; every name is fictional.""" +removed. Also: the REAL review_checks.sh runs end to end on a file:// repo; owner/.github is accepted, owner/.. rejected; a failing review_checks puts its last stderr line in the reason. No network; every name is fictional.""" import glob, os, shutil, subprocess, sys, tempfile D = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".claude", "skills", "deep-code-review", "scripts", "org_audit_driver.py") @@ -64,7 +64,7 @@ def main(): check(rows["acme/missing"][1] == "FAILED" and rows["bad name"][1] == "FAILED", "failed rows missing") flags, sg, n, clone, wt, hd, ln = open(log).read().splitlines() check("--full-history" in flags and "--tests" not in flags, f"flags: {flags}") - check("--base 4b825dc642cb6eb9a060e54bf8d69288fbee4904" in flags, "empty-tree base missing") + check("--full --full-history" in flags and "--base" not in flags, "want --full, no --base") check(sg.endswith("templates/semgrep-taint"), f"semgrep config not forced: {sg}") check(n == "3", f"want 2 history commits + 1 strip commit: {n}") check(wt == "0" and hd == "0", f"repo-local gitleaks config still present: tree {wt}, HEAD {hd}") @@ -88,6 +88,19 @@ def main(): os.chmod(os.path.join(wrap, "git"), 0o755) r, _, rows = drive(t, "acme/solo\n", dict(env, PATH=wrap + ":" + os.environ["PATH"]), "e") check(r.returncode == 1 and rows["acme/solo"][1] == "FAILED" and "commit" in rows["acme/solo"][3], f"strip commit failure: {r.stdout}") + # real review_checks.sh, no override: scan completes (fails if --base gets the empty-tree hash) + mk(t, ".github", 1) + renv = {k: v for k, v in env.items() if k != "ORG_AUDIT_REVIEW_CHECKS"} + r, _, rows = drive(t, "acme/.github\nacme/..\nacme/.\n", renv, "f") + check(rows["acme/.github"][1] == "OK", f"real review_checks / dot repo: {r.stdout} {r.stderr}") + check(rows["acme/.."][1] == "FAILED" and "bad repo name" in rows["acme/.."][3], "owner/.. accepted") + check(rows["acme/."][1] == "FAILED", "owner/. accepted") + # a failing review_checks: last non-empty stderr line lands in the reason, tabs/newlines gone, cut at 200 + bad = os.path.join(t, "bad.sh") + open(bad, "w").write('#!/usr/bin/env bash\necho first >&2\nprintf "boom\\t%0300d\\n\\n" 0 >&2\nexit 3\n') + r, _, rows = drive(t, "acme/solo\n", dict(env, ORG_AUDIT_REVIEW_CHECKS=bad), "g") + reason = rows["acme/solo"][3] + check("(exit 3): boom 000" in reason and "first" not in reason and len(reason) < 260, f"stderr in reason: {reason}") print(f"Tests: {N[0]}/{N[0]}") From f3ae2369da8877035a8a68e40015b97ca1ec677d Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:35:06 +0100 Subject: [PATCH 3/4] org_audit_driver: token-scoped private clones, scanner env stripped, 0700 out If ORG_AUDIT_TOKEN is set, the token reaches only the git clone and fetch children, as an http.extraHeader passed through GIT_CONFIG_COUNT/KEY_n/VALUE_n environment entries appended after any the parent already has (never in the URL, argv or .git/config). review_checks and its scanners run with ORG_AUDIT_TOKEN, GH_TOKEN and GITHUB_TOKEN removed and never see the header. The token is masked in FAILED reasons, and --out is created 0700. Verified on a real run of 13 private repositories: all scanned OK, and the token appears in no output file. Tests: 26/26 Co-Authored-By: Claude Opus 5.5 --- .../scripts/org_audit_driver.py | 24 +++++++++++++---- scripts/test_org_audit_driver.py | 26 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.claude/skills/deep-code-review/scripts/org_audit_driver.py b/.claude/skills/deep-code-review/scripts/org_audit_driver.py index a46683fb5..3b5aecdc3 100644 --- a/.claude/skills/deep-code-review/scripts/org_audit_driver.py +++ b/.claude/skills/deep-code-review/scripts/org_audit_driver.py @@ -9,6 +9,10 @@ (repo, status OK|FAILED, findings, NOT CHECKED tools or the failure reason). A clone or scan failure is a FAILED row (a review_checks failure appends its last stderr line, 200 chars max), never skipped. Exit 0 = every repo scanned; 1 = any FAILED; 2 = usage or an empty list. NOT CHECKED tools do not fail the run but are listed per repo: they are unchecked surface, not a clean result. +Private repos: if ORG_AUDIT_TOKEN is set it reaches ONLY the git clone/fetch children, as an `http.extraHeader` Basic +auth header appended to the parent's GIT_CONFIG_COUNT/KEY_n/VALUE_n env at index COUNT (parent entries kept) (never in the URL, argv or .git/config). Every other child +(review_checks and its scanners) runs with ORG_AUDIT_TOKEN, GH_TOKEN, GITHUB_TOKEN removed and no header, and the token +(raw and base64) is masked in any FAILED reason. --out is created (or chmod-ed) 0700. Env: ORG_AUDIT_BASE_URL (default https://github.com) and ORG_AUDIT_REVIEW_CHECKS (script path) exist for tests. Side effects: network clones; writes only under --out and its own temp dir, which is removed on exit. Repo-local scanner config (.gitleaks.toml, .gitleaks-allow, .gitleaksignore, .trivyignore, ...) is deleted in the temp clone @@ -17,7 +21,7 @@ Every tracked symlink is removed too. Children run with stdin closed, no prompts, no host git config, hooks off, and are killed (whole process group) on --timeout. Two repos mapping to one output name: the later is FAILED. """ -import argparse, datetime, json, os, re, signal, subprocess, sys, tempfile +import argparse, base64, datetime, json, os, re, signal, subprocess, sys, tempfile HERE = os.path.dirname(os.path.abspath(__file__)) # Repo-root config an untrusted repo could use to allowlist or silence its own findings; review_checks.sh and the @@ -29,7 +33,8 @@ # Neutral env for every child: no prompts, no host/system git config, only https/file transports, C locale. -ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0", LC_ALL="C", GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null", +SECRET_ENV = ("ORG_AUDIT_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") +ENV = dict({k: v for k, v in os.environ.items() if k not in SECRET_ENV}, GIT_TERMINAL_PROMPT="0", LC_ALL="C", GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null", GIT_ALLOW_PROTOCOL="https:file") GIT = ["git", "-c", "core.hooksPath=/dev/null"] @@ -51,8 +56,12 @@ def audit(repo, tmp, rc_script, base_url, timeout): """Return (findings_json_text, rows, not_checked) or raise RuntimeError(reason).""" dest = os.path.join(tmp, "clone") url = f"{base_url}/{repo}.git" + tok = os.environ.get("ORG_AUDIT_TOKEN") + n = int(ENV.get("GIT_CONFIG_COUNT") or 0) # keep parent proxy/CA entries, append the header after them + genv = ENV if not tok else dict(ENV, GIT_CONFIG_COUNT=str(n + 1), **{f"GIT_CONFIG_KEY_{n}": "http.extraHeader", + f"GIT_CONFIG_VALUE_{n}": "Authorization: Basic " + base64.b64encode(f"x-access-token:{tok}".encode()).decode()}) for cmd in (GIT + ["clone", "--quiet", "--depth", "1", "--", url, dest], GIT + ["-C", dest, "fetch", "--quiet", "--unshallow"]): - r = run(cmd, timeout=timeout) + r = run(cmd, timeout=timeout, env=genv) if r.returncode and "--unshallow" in cmd and "does not make sense" in r.stderr: continue # already complete (one-commit repo): nothing to unshallow if r.returncode: @@ -103,7 +112,8 @@ def main(): if not names: print("org_audit_driver: empty repo list", file=sys.stderr) return 2 - os.makedirs(a.out, exist_ok=True) + os.makedirs(a.out, mode=0o700, exist_ok=True) + os.chmod(a.out, 0o700) day = datetime.date.today().strftime("%Y%m%d") rc_script = os.environ.get("ORG_AUDIT_REVIEW_CHECKS") or os.path.join(HERE, "review_checks.sh") base_url = os.environ.get("ORG_AUDIT_BASE_URL", "https://github.com").rstrip("/") @@ -123,7 +133,11 @@ def main(): lines.append(f"{repo}\tOK\t{len(rows)}\t" + ("; ".join(nc) or "-")) except (RuntimeError, subprocess.TimeoutExpired, OSError) as e: failed += 1 - lines.append(f"{repo}\tFAILED\t-\t{str(e).replace(chr(9), ' ').replace(chr(10), ' ')}") + msg = str(e).replace(chr(9), ' ').replace(chr(10), ' ') + tok = os.environ.get("ORG_AUDIT_TOKEN") + for secret in (tok, tok and base64.b64encode(f"x-access-token:{tok}".encode()).decode()): + msg = msg.replace(secret, "***") if secret else msg + lines.append(f"{repo}\tFAILED\t-\t{msg}") out = "repo\tstatus\tfindings\tnot_checked_or_reason\n" + "\n".join(lines) + "\n" with open(os.path.join(a.out, f"summary-{day}.tsv"), "w") as f: f.write(out) diff --git a/scripts/test_org_audit_driver.py b/scripts/test_org_audit_driver.py index 1c5ef808c..9c3124fee 100644 --- a/scripts/test_org_audit_driver.py +++ b/scripts/test_org_audit_driver.py @@ -5,7 +5,7 @@ .gitleaks.toml/.gitleaksignore and every symlink are gone from the scanned tree and HEAD; a one-commit repo works; a failing config-strip commit fails closed; a duplicate output name is FAILED; an empty list exits 2; the temp clone is removed. Also: the REAL review_checks.sh runs end to end on a file:// repo; owner/.github is accepted, owner/.. rejected; a failing review_checks puts its last stderr line in the reason. No network; every name is fictional.""" -import glob, os, shutil, subprocess, sys, tempfile +import base64, glob, os, shutil, subprocess, sys, tempfile D = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".claude", "skills", "deep-code-review", "scripts", "org_audit_driver.py") STUB = """#!/usr/bin/env bash @@ -101,6 +101,30 @@ def main(): r, _, rows = drive(t, "acme/solo\n", dict(env, ORG_AUDIT_REVIEW_CHECKS=bad), "g") reason = rows["acme/solo"][3] check("(exit 3): boom 000" in reason and "first" not in reason and len(reason) < 260, f"stderr in reason: {reason}") + # private-clone token: scoped to clone/fetch only, never leaked + tok = "sentinel-tok-9f3a" + hdr = "Authorization: Basic " + base64.b64encode(f"x-access-token:{tok}".encode()).decode() + glog, gw = os.path.join(t, "glog"), os.path.join(t, "gbin") + os.makedirs(gw) + open(os.path.join(gw, "git"), "w").write(f'#!/bin/sh\ncase " $* " in *" clone "*) {{ echo "ARGV $*"; env | grep -E "^GIT_CONFIG_(COUNT|KEY|VALUE)" | sort; }} >>"{glog}";; esac\nexec {real} "$@"\n') + os.chmod(os.path.join(gw, "git"), 0o755) + senv = dict(env, ORG_AUDIT_TOKEN=tok, GH_TOKEN=tok, GITHUB_TOKEN=tok, GIT_CONFIG_COUNT="2", GIT_CONFIG_KEY_0="core.abbrev", + GIT_CONFIG_VALUE_0="7", GIT_CONFIG_KEY_1="core.quotepath", GIT_CONFIG_VALUE_1="false", PATH=gw + ":" + os.environ["PATH"]) + senv["ORG_AUDIT_REVIEW_CHECKS"] = vis = os.path.join(t, "vis.sh") + open(vis, "w").write('#!/usr/bin/env bash\nenv | grep -E "^(ORG_AUDIT_TOKEN|GH_TOKEN|GITHUB_TOKEN|GIT_CONFIG_(COUNT|KEY|VALUE))" | sort >"$STUB_LOG"; echo "[]"\n') + r, out, rows = drive(t, "acme/solo\nacme/missing\n", senv, "h") + check(rows["acme/solo"][1] == "OK" and open(log).read() == "GIT_CONFIG_COUNT=2\nGIT_CONFIG_KEY_0=core.abbrev\nGIT_CONFIG_KEY_1=core.quotepath\nGIT_CONFIG_VALUE_0=7\nGIT_CONFIG_VALUE_1=false\n", f"scanner env: {open(log).read()!r}") + g = open(glog).read() + check(f"GIT_CONFIG_COUNT=3\nGIT_CONFIG_KEY_0=core.abbrev\nGIT_CONFIG_KEY_1=core.quotepath\nGIT_CONFIG_KEY_2=http.extraHeader\nGIT_CONFIG_VALUE_0=7\nGIT_CONFIG_VALUE_1=false\nGIT_CONFIG_VALUE_2={hdr}" in g and tok not in g.replace(hdr, "") and "ARGV" in g, "clone env lacks header or argv carries token") + blob = r.stdout + r.stderr + "".join(open(f).read() for f in glob.glob(out + "/*")) + check(tok not in blob and hdr.split()[-1] not in blob and rows["acme/missing"][1] == "FAILED", "token leaked in output") + open(bad, "w").write(f'#!/usr/bin/env bash\necho "boom {tok} $(printf x:{tok} | true)" >&2\nexit 3\n') + r, out, rows = drive(t, "acme/solo\n", dict(senv, ORG_AUDIT_REVIEW_CHECKS=bad), "i") + check(rows["acme/solo"][1] == "FAILED" and tok not in r.stdout + r.stderr, "token in FAILED reason") + check(os.stat(out).st_mode & 0o777 == 0o700, "out not 0700") + os.chmod(out, 0o755) + drive(t, "acme/solo\n", env, "i") + check(os.stat(out).st_mode & 0o777 == 0o700, "existing out not chmod 0700") print(f"Tests: {N[0]}/{N[0]}") From 5ae53abf413498f01564d63ecd4c012e026c24cb Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:35:37 +0100 Subject: [PATCH 4/4] Raise agentic-delivery index lookup budget for org_settings_audit row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorten the org audit script summaries and split their usage paragraphs so the generated index rows stay short. Raise the three index budget rows the regenerated indexes exceed, and reset the lookup-cost cap in test_skill_index_split to the measured lookup plus 100 bytes. Tests: 61/61 size-budget-raise: .claude/skills/INDEX.md 17629→18145 org audit tool rows in the entry index size-budget-raise: .claude/skills/agentic-delivery/INDEX-scripts.md 17639→17829 org_settings_audit row size-budget-raise: .claude/skills/deep-code-review/INDEX-scripts.md 8523→9207 org_audit_driver, org_inventory, org_leak_sweep rows Co-Authored-By: Claude Haiku 5.5 --- .claude/skills/INDEX.md | 4 ++++ .claude/skills/agentic-delivery/INDEX-scripts.md | 3 ++- .claude/skills/agentic-delivery/INDEX.md | 2 +- .../skills/agentic-delivery/scripts/org_settings_audit.py | 3 ++- .claude/skills/deep-code-review/INDEX-scripts.md | 3 +++ .claude/skills/deep-code-review/INDEX.md | 4 ++-- .claude/skills/deep-code-review/scripts/org_audit_driver.py | 3 ++- .claude/skills/deep-code-review/scripts/org_inventory.py | 2 +- .claude/skills/deep-code-review/scripts/org_leak_sweep.py | 2 +- scripts/size-budgets.tsv | 6 +++--- scripts/test_skill_index_split.py | 2 +- 11 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index 7cfe11e0e..5ecb83151 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -75,6 +75,7 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - merge_train.py: land several green PRs as ONE verified union (a merge train). — plan --base main [--repo OWNER/NAME]… - mutation_changed.py: Mutation testing restricted to the lines a diff changed. — [--base origin/main] [--max-seconds… - operating_selfcheck.py: per-item PRESENT / MISSING / COULD_NOT_CHECK report for the… — [--settings FILE] [--skill-root DIR] +- org_settings_audit.py: read-only audit of GitHub org settings, then each repo's. — ORG [--max-repos N] [--token-env VAR]… - partial_pass.py: PARTIAL-PASS receipt for a PR whose precheck ended… — write PR --head SHA --unchecked NAME… - perun_policy.py: read the one resource-policy file, .perun/policy.json. — get - pipeline_hub.py: one entry point that says where a delivery pipeline loses… — [--repo OWNER/NAME] [--root DIR]… @@ -147,6 +148,9 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - impact_map.py: map a diff's changed symbols to callers and callees. — [--base REF] [--repo DIR] [--cap N] - lint_ui_slop.py: zero-token, deterministic lint for common UI defects in HTML… — [--json] [--tokens FILE] PATH... - merge_findings.py: dedupe, rank and cap findings from one or more passes. — [--cap N] [--root DIR] [--ref REF]… +- org_audit_driver.py: review_checks.sh over a repo list, one result file per repo. — --repos FILE --out DIR [--timeout SECS] +- org_inventory.py: dated TSV inventory of GitHub org repos, reconciled with a… — --org ORG [--org ORG ...] [--out DIR]… +- org_leak_sweep.py: scan an org's public repos (or one local repo) for secrets and… — --org ORG [--banlist FILE] [--out DIR]… - outline.py: cheap symbol map (line ranges) so a lane reads only the ranges… — FILE... - ownership_sibling_check.py: lead-finder for broken object-level authorization (OWASP API1… — PATH... - parity_differ.py: the two-sided design<->app parity gate: sections first, then… — --design --app [--accept… diff --git a/.claude/skills/agentic-delivery/INDEX-scripts.md b/.claude/skills/agentic-delivery/INDEX-scripts.md index e770fa73d..73afd4dc2 100644 --- a/.claude/skills/agentic-delivery/INDEX-scripts.md +++ b/.claude/skills/agentic-delivery/INDEX-scripts.md @@ -62,6 +62,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/merge_train.py` | land several green PRs as ONE verified union (a merge train). | merge_train.py plan --base main [--repo OWNER/NAME] [--min-checks K] merge_train.py verify --base main --verify-cmd 'ma… | | `scripts/mutation_changed.py` | Mutation testing restricted to the lines a diff changed. | mutation_changed.py [--base origin/main] [--max-seconds 300] [--strict] [--selftest] | | `scripts/operating_selfcheck.py` | per-item PRESENT / MISSING / COULD_NOT_CHECK report for the always-on operating layer references/op… | operating_selfcheck.py [--settings FILE] [--skill-root DIR] operating_selfcheck.py --selftest | +| `scripts/org_settings_audit.py` | org_settings_audit.py: read-only audit of GitHub org settings, then each repo's. | org_settings_audit.py ORG [--max-repos N] [--token-env VAR] [--json] | | `scripts/outcome_claim_guard.py` | Stop + PreToolUse + UserPromptSubmit hook: no "fixed / faster / done" on delivery outcomes without… | — | | `scripts/partial_pass.py` | PARTIAL-PASS receipt for a PR whose precheck ended COULD_NOT_CHECK on some checks (#1778). | partial_pass.py write PR --head SHA --unchecked NAME [NAME ...] | | `scripts/peer_ack.py` | read receipts for peer directives, on the coordination board: no ack within T seconds means undeliv… | — | @@ -84,7 +85,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/refix_gate.py` | re-touching a recently-fixed file requires a class-level artifact. | refix_gate.py --base REF --head REF [--window-hours 72] [--class-glob GLOB ...] refix_gate.py --selftest | | `scripts/repeat_poll_guard.py` | PreToolUse(Bash) hook: nudge when the same command runs more than 3 times in one session. | — | | `scripts/reply_cap.py` | owner rule: chat replies max 3 lines, 500 chars. | — | -| `scripts/repo_settings_audit.py` | repo_settings_audit.py: read-only audit of the GitHub settings that decide who can change a reposit… | repo_settings_audit.py OWNER/NAME [OWNER/NAME ...] [--repos-file FILE] [--json] --repos-file holds one OWNER/NAME per l… | +| `scripts/repo_settings_audit.py` | repo_settings_audit.py: read-only audit of the GitHub settings that decide who can change a reposit… | repo_settings_audit.py OWNER/NAME [OWNER/NAME ...] [--repos-file FILE] [--json] [--scorecard] --repos-file holds one OW… | | `scripts/restore_ledger.py` | classify every branch after a VCS history-rewrite incident. | — | | `scripts/review_digest.py` | a returning reviewer's change digest, not a full re-read. | review_digest.py --since SHA [--head REF] [--paths PATH ...] [--max-lines N] [--allow-diverged] review_digest.py --self… | | `scripts/review_gate.py` | pre-merge gate: every PR needs an independent deep-code-review DIFF pass plus an evil-twin check. | review_gate.py check --head SHA [--repo R] | diff --git a/.claude/skills/agentic-delivery/INDEX.md b/.claude/skills/agentic-delivery/INDEX.md index 326b32819..03cb81e2f 100644 --- a/.claude/skills/agentic-delivery/INDEX.md +++ b/.claude/skills/agentic-delivery/INDEX.md @@ -38,4 +38,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -110 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### agentic-delivery`, has only each one-line purpose and arguments. +111 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### agentic-delivery`, has only each one-line purpose and arguments. diff --git a/.claude/skills/agentic-delivery/scripts/org_settings_audit.py b/.claude/skills/agentic-delivery/scripts/org_settings_audit.py index c7b045359..67ee3b06d 100644 --- a/.claude/skills/agentic-delivery/scripts/org_settings_audit.py +++ b/.claude/skills/agentic-delivery/scripts/org_settings_audit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""org_settings_audit.py: read-only audit of GitHub organization settings, then every repository's settings. +"""org_settings_audit.py: read-only audit of GitHub org settings, then each repo's. WHY: branch protection, required 2FA, Actions token defaults, installed apps and self-hosted runners are not visible in code, yet they decide whether a review gate means anything (the CIS GitHub benchmark covers them). Per repository this @@ -14,6 +14,7 @@ never a pass. USAGE: org_settings_audit.py ORG [--max-repos N] [--token-env VAR] [--json] + Token: the env var named by --token-env, else `gh auth token`; it reaches curl on stdin only (never argv), as in org_leak_sweep.py. `GITHUB_API_URL` overrides https://api.github.com (tests point it at a stub). Output: `{"org": rows, "repos": {"OWNER/NAME": rows}, "summary": {...}}` with `--json`, else one table. diff --git a/.claude/skills/deep-code-review/INDEX-scripts.md b/.claude/skills/deep-code-review/INDEX-scripts.md index c13d4ec64..584db4aea 100644 --- a/.claude/skills/deep-code-review/INDEX-scripts.md +++ b/.claude/skills/deep-code-review/INDEX-scripts.md @@ -31,6 +31,9 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/invisible_chars.py` | flag NUL, control and hidden characters in text files. | — | | `scripts/lint_ui_slop.py` | zero-token, deterministic lint for common UI defects in HTML, CSS, JSX and TSX. | lint_ui_slop.py [--json] [--tokens FILE] PATH... lint_ui_slop.py --hook < hook-event.json | | `scripts/merge_findings.py` | dedupe, rank and cap findings from one or more passes. | merge_findings.py [--cap N] [--root DIR] [--ref REF] ... | +| `scripts/org_audit_driver.py` | org_audit_driver.py: review_checks.sh over a repo list, one result file per repo. | org_audit_driver.py --repos FILE --out DIR [--timeout SECS] | +| `scripts/org_inventory.py` | org_inventory.py: dated TSV inventory of GitHub org repos, reconciled with a previous run. | org_inventory.py --org ORG [--org ORG ...] [--out DIR] [--previous TSV] [--results-dir DIR] [--max-age-days N] [--token… | +| `scripts/org_leak_sweep.py` | org_leak_sweep.py: scan an org's public repos (or one local repo) for secrets and banned terms. | org_leak_sweep.py --org ORG [--banlist FILE] [--out DIR] [--token-env VAR] org_leak_sweep.py --local REPO [--banlist FI… | | `scripts/outline.py` | cheap symbol map (line ranges) so a lane reads only the ranges it needs, not the whole file. | outline.py FILE... | | `scripts/ownership_sibling_check.py` | lead-finder for broken object-level authorization (OWASP API1, CWE-639) by sibling comparison. | ownership_sibling_check.py PATH... | | `scripts/parity_differ.py` | the two-sided design<->app parity gate: sections first, then the element inventory of every matched… | parity_differ.py --design --app [--accept [--accept-rev REV]] [--min-pairs N] [--style [--style-min… | diff --git a/.claude/skills/deep-code-review/INDEX.md b/.claude/skills/deep-code-review/INDEX.md index 97949b111..4245f6128 100644 --- a/.claude/skills/deep-code-review/INDEX.md +++ b/.claude/skills/deep-code-review/INDEX.md @@ -138,7 +138,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/testing-ui.md` | the target renders a UI or ships browser / E2E specs. | 4868 | | `references/threat-modeling.md` | a change adds a trust boundary, an auth path, a data flow to a new party, an agent or tool capabili… | 2574 | | `references/time-date-correctness.md` | reviewing anything that stores, computes, schedules, or renders timestamps, durations, recurring ev… | 4809 | -| `references/tool-hub.md` | a review should lean on external scanners via the tool hub (workflows, Python, Go, SBOM/lockfiles,… | 3942 | +| `references/tool-hub.md` | a review should lean on external scanners via the tool hub (workflows, Python, Go, SBOM/lockfiles,… | 4853 | | `references/ux-components.md` | the target or diff renders one concept in more than one place: a shared component or design token,… | 7235 | | `references/ux-dataviz.md` | the target or diff renders a chart, sparkline, heat-map or calendar grid, a KPI / metric delta, a c… | 2787 | | `references/ux-gates.md` | a UI change is claimed verified on screenshot evidence, the project's auto-merge or CI policy gates… | 3756 | @@ -153,4 +153,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -48 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. +51 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. diff --git a/.claude/skills/deep-code-review/scripts/org_audit_driver.py b/.claude/skills/deep-code-review/scripts/org_audit_driver.py index 3b5aecdc3..e0b9de044 100644 --- a/.claude/skills/deep-code-review/scripts/org_audit_driver.py +++ b/.claude/skills/deep-code-review/scripts/org_audit_driver.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -"""org_audit_driver.py: run review_checks.sh over a list of repos, one dated result file each, plus a summary. +"""org_audit_driver.py: review_checks.sh over a repo list, one result file per repo. Usage: org_audit_driver.py --repos FILE --out DIR [--timeout SECS] + FILE: one `owner/repo` per line ('#' and blank lines skipped). Per repo: shallow clone then `git fetch --unshallow` into a fresh mkdtemp dir, then `review_checks.sh --full --full-history` (never --tests, which runs repo code). Semgrep is forced to the bundled rules via REVIEW_SEMGREP_CONFIG, so a repo's own .semgrep.yml is ignored. diff --git a/.claude/skills/deep-code-review/scripts/org_inventory.py b/.claude/skills/deep-code-review/scripts/org_inventory.py index da4567d5e..aee732e4a 100644 --- a/.claude/skills/deep-code-review/scripts/org_inventory.py +++ b/.claude/skills/deep-code-review/scripts/org_inventory.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""org_inventory.py: dated TSV inventory of every repo in one or more GitHub orgs, reconciled against a previous run and a results dir. +"""org_inventory.py: dated TSV inventory of GitHub org repos, reconciled with a previous run. Usage: org_inventory.py --org ORG [--org ORG ...] [--out DIR] [--previous TSV] [--results-dir DIR] [--max-age-days N] [--token-env VAR] [--api-url URL] diff --git a/.claude/skills/deep-code-review/scripts/org_leak_sweep.py b/.claude/skills/deep-code-review/scripts/org_leak_sweep.py index f969e3623..18cfa9864 100644 --- a/.claude/skills/deep-code-review/scripts/org_leak_sweep.py +++ b/.claude/skills/deep-code-review/scripts/org_leak_sweep.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""org_leak_sweep.py: sweep every public repo of a GitHub org (or one local repo) for secrets and banned terms across full history. +"""org_leak_sweep.py: scan an org's public repos (or one local repo) for secrets and banned terms. Usage: org_leak_sweep.py --org ORG [--banlist FILE] [--out DIR] [--token-env VAR] org_leak_sweep.py --local REPO [--banlist FILE] [--out DIR] (no network; used by the test) diff --git a/scripts/size-budgets.tsv b/scripts/size-budgets.tsv index f4cd89bed..0ecaa0cc2 100644 --- a/scripts/size-budgets.tsv +++ b/scripts/size-budgets.tsv @@ -208,11 +208,11 @@ README.md 16118 .claude/skills/product-discovery/references/full-map.md 12384 .claude/skills/growth-analytics/references/full-map.md 10661 .claude/skills/positioning/references/full-map.md 9520 -.claude/skills/INDEX.md 17629 +.claude/skills/INDEX.md 18145 .claude/skills/agentic-delivery/INDEX.md 4672 -.claude/skills/agentic-delivery/INDEX-scripts.md 17639 +.claude/skills/agentic-delivery/INDEX-scripts.md 17829 .claude/skills/deep-code-review/INDEX.md 20703 -.claude/skills/deep-code-review/INDEX-scripts.md 8523 +.claude/skills/deep-code-review/INDEX-scripts.md 9207 .claude/skills/agentic-delivery/references/pipeline-optimizer.md 21785 .claude/skills/agentic-delivery/references/focus-kit.md 5235 .claude/skills/communication-structure/references/artifact-templates.md 9443 diff --git a/scripts/test_skill_index_split.py b/scripts/test_skill_index_split.py index c477214ef..484025f0d 100644 --- a/scripts/test_skill_index_split.py +++ b/scripts/test_skill_index_split.py @@ -92,7 +92,7 @@ def test_lookup_cost_is_measured_not_assumed(self): d = ROOT / ".claude/skills/agentic-delivery" core, lazy = (d / "INDEX.md").stat().st_size, (d / si.SCRIPTS_INDEX).stat().st_size self.assertLess(core, 16962) # a reference lookup reads core only - self.assertLess(core + lazy, 16962 + 5500) # a script lookup reads both: about the old size, not far above; +130 for the reply_cap and decision_gate rows, +820 for the destructive_guard_core, file_finding, repeat_poll_guard, stage_run and subagent_brief_lint rows, +150 for the feeder_order row, +200 for the train_yield row, +100 for the lkg row, +200 for the red_main row and the stage-weights/serial CLI text, +400 = +348 INDEX-scripts.md (14732->15080: host_installer, peer_ack, pregate_parity rows) +51 skills/INDEX.md (14295->14346), +100 for the vendor_drift row, +200 = +284 INDEX-scripts.md growth (admission_check, host_accept rows) plus INDEX.md regen, +100 for the lane_worktree row, +50 for the bg_admit row, +150 for wave L28 rows (council, flow_health), +1000 for wave AL rows (reply_cap, fitness_tick, flake_holder, finding_guard, baseline_guard, freeze_gate, flow_alarm, prevention_lint, quarantine; measured 21360), +300 for the freeze_hook and flow-guards rows (measured 21617), +300 for the pipeline_hub row (measured 21895), +300 for the repo_settings_audit row (measured 22162), +200 for the privacy_guard row (measured 22303) + self.assertLess(core + lazy, 16962 + 5639) # a script lookup reads both: about the old size, not far above; +130 for the reply_cap and decision_gate rows, +820 for the destructive_guard_core, file_finding, repeat_poll_guard, stage_run and subagent_brief_lint rows, +150 for the feeder_order row, +200 for the train_yield row, +100 for the lkg row, +200 for the red_main row and the stage-weights/serial CLI text, +400 = +348 INDEX-scripts.md (14732->15080: host_installer, peer_ack, pregate_parity rows) +51 skills/INDEX.md (14295->14346), +100 for the vendor_drift row, +200 = +284 INDEX-scripts.md growth (admission_check, host_accept rows) plus INDEX.md regen, +100 for the lane_worktree row, +50 for the bg_admit row, +150 for wave L28 rows (council, flow_health), +1000 for wave AL rows (reply_cap, fitness_tick, flake_holder, finding_guard, baseline_guard, freeze_gate, flow_alarm, prevention_lint, quarantine; measured 21360), +300 for the freeze_hook and flow-guards rows (measured 21617), +300 for the pipeline_hub row (measured 21895), +300 for the repo_settings_audit row (measured 22162), +200 for the privacy_guard row (measured 22303), +200 for the org_settings_audit row (measured 22501) def test_entry_index_points_at_the_lazy_file(self): self.assertIn(si.SCRIPTS_INDEX, (ROOT / ".claude/skills/INDEX.md").read_text(encoding="utf-8"))