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 new file mode 100644 index 000000000..67ee3b06d --- /dev/null +++ b/.claude/skills/agentic-delivery/scripts/org_settings_audit.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""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 +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/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/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..e0b9de044 --- /dev/null +++ b/.claude/skills/deep-code-review/scripts/org_audit_driver.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""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. +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 (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 +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, 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 +# 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._-]*/(?!\.{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. +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"] + + +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" + 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, 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: + 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})") + env = dict(ENV, REVIEW_SEMGREP_CONFIG=os.path.join(HERE, "..", "templates", "semgrep-taint")) + 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}){why}") + if 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) + + +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, 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("/") + 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 + 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) + 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..aee732e4a --- /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 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] + +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/.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/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..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 @@ -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..9c3124fee --- /dev/null +++ b/scripts/test_org_audit_driver.py @@ -0,0 +1,131 @@ +#!/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, --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. 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 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 +{ 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("--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}") + 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}") + # 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}") + # 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]}") + + +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() 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"))