From 41937b8959d7168b6fc1ba54339837308fdff769 Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:43:17 +0100 Subject: [PATCH] Wave 11: guard registry, privacy guard on GitHub writes, new-row flags in review_checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard registry (refs #1592): - Every guard rail Perun ships has one row in scripts/guard-registry.tsv: id, owner role (never a person), reason, review-by date and an optional fire log path. A guard is a hook a shipped settings template runs, a git hook install.sh writes, or a ci-gates.sh subcommand. - ci-gates.sh guard-registry fails closed on a guard with no row, an empty reason, an owner that is not a lowercase role slug, a review-by that is not a real calendar day, a duplicate or over-wide row, a row for a guard that no longer ships, an empty scan, and a cmd_ function whose dispatcher line the scan cannot read. A past review-by never fails it. - guard-registry --report [--days N] is read-only: rows past review-by, and logged guards with no dated fire line in the last N days. A guard with no fire log, or an empty, rotated or undated one, is "unknown", never "zero fires". The report never edits or deletes a row. - The header-parity test pins each hook row's owner, reason and review date to its script's `# rail:` line. Evil-twin record: keeping both the headers and the TSV can drift; if they still disagree after about 30 days, retire one. Privacy guard (new hook, agentic-delivery/privacy_guard.py): - A PreToolUse(Bash) hook runs the repo's banlist (.banlist.txt, then the gitignored .banlist.local.txt at the git top-level; a linked worktree reads the main checkout's local file) over the title and body of every gh pr|issue|release create|edit|comment and every curl or gh api write to repos///pulls or /issues in the command. A hit exits 2 with a reason that names the title or the body line and the pattern's number, never the matched text. No repo, no banlist, no title or body, an unreadable body file (a readable title is still checked), bad input or any error allows; PERUN_PRIVACY_GUARD=off opts out. - Scope: the banlist covers writes into the session's own repository, into protocol/perun and into any repository under another owner. A write whose explicit target (read from that write's own flags, never from its title, body or a later command) is a different repository under the session origin's owner is not scanned, so an audit PR may name the repo it is filed in. A write naming several repositories is skipped only when every one is a sibling. With no usable origin every write is scanned. Known residuals, documented in the module: a public sibling repository in the session's own organisation is not scanned, a write into another owner's private repository is still scanned (a private target cannot be told from a public one without a network call), and a target set only by GH_REPO is not seen. - `-f`/`--raw-field` text is scanned as written even when it starts with `@`; only `-F`/`--field` reads `@FILE`, and a failed read scans the literal text. - A body file the command writes itself (`> F`, `>> F`, `tee F`) is read from the heredoc that feeds that file, not from the stale or missing file. - Own-repository rule: a write into Perun's public repository from another repository's session is denied when its title or body names the session's repository (owner/name, or the bare name when distinctive), because a project's banlist does not list its own name. The reason gives the location only. - comms_guard.py extraction, shared by import: `-F body=@FILE` reads @FILE as one shell word, and `_flags` masks quoted strings and heredoc bodies so a flag-like word inside a title or body is not taken for a flag. - Wired into the operating-layer template, perun_doctor HOOK_SCRIPTS, a guard-registry row, a `# rail:` header and a changelog fragment. Unscanned forms (stdin pipes, merge and review, --fill, text built from a shell variable) are listed in the module docstring. Review (review_checks.sh): - In diff mode every JSON row carries "new": true|false, true when the row's file and line sit on a line BASE...HEAD adds (rename-aware `git diff -U0` with pinned flags, so a reviewer's git config cannot change which lines count). Line 0 and file-level rows are false; --full and --history omit the key; no row is dropped. stderr gains "review_checks: N of M rows are on lines this diff adds". - references/method-situational.md: verify new rows first, and report a pre-existing row in a touched file only when the diff makes it reachable or worse. Index: the agentic-delivery script-lookup cap in scripts/test_skill_index_split.py rises by 200 bytes for the new privacy_guard row (core plus lazy index measured at 22303 bytes). size-budget-raise: .claude/skills/deep-code-review/references/method-situational.md 44396→44702 one sentence on the new flag: verify new rows first, report a pre-existing row only when the diff makes it reachable or worse Tests: 259/259 in the 9 suites affected by the last two commits, re-run at the final head (audit-pr-body 46, comms-quality 51, conflict-free-prs 23, guard-registry 92, privacy-guard 40, skill-index-split 7). Earlier assembly runs: the suites affected by the first commits were green; 47 of the 48 suites affected by the review round were green and the 48th (the index cap) is fixed here. The full set runs in CI. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/INDEX.md | 2 +- .../skills/agentic-delivery/INDEX-scripts.md | 1 + .claude/skills/agentic-delivery/INDEX.md | 2 +- .../agentic-delivery/scripts/comms_guard.py | 44 +- .../agentic-delivery/scripts/privacy_guard.py | 239 ++++++++++ .../templates/operating-layer.settings.json | 10 + .claude/skills/deep-code-review/INDEX.md | 2 +- .../references/method-situational.md | 2 +- .../deep-code-review/scripts/review_checks.sh | 18 +- SHA256SUMS | 15 +- changelog.d/guard-registry.md | 7 + changelog.d/privacy-guard.md | 13 + changelog.d/review-checks-new-rows.md | 5 + scripts/ci-gates.sh | 128 +++++ scripts/ci-tests.d/guard-registry.sh | 2 + scripts/ci-tests.d/privacy-guard.sh | 2 + scripts/ci-tests.d/review-checks-new-rows.sh | 2 + scripts/guard-registry.tsv | 51 ++ scripts/perun_doctor.py | 2 +- scripts/size-budgets.tsv | 6 +- scripts/test-guard-registry.sh | 239 ++++++++++ scripts/test-review-checks-new-rows.sh | 63 +++ scripts/test_privacy_guard.py | 437 ++++++++++++++++++ scripts/test_skill_index_split.py | 2 +- 24 files changed, 1265 insertions(+), 29 deletions(-) create mode 100644 .claude/skills/agentic-delivery/scripts/privacy_guard.py create mode 100644 changelog.d/guard-registry.md create mode 100644 changelog.d/privacy-guard.md create mode 100644 changelog.d/review-checks-new-rows.md create mode 100644 scripts/ci-tests.d/guard-registry.sh create mode 100644 scripts/ci-tests.d/privacy-guard.sh create mode 100644 scripts/ci-tests.d/review-checks-new-rows.sh create mode 100644 scripts/guard-registry.tsv create mode 100644 scripts/test-guard-registry.sh create mode 100644 scripts/test-review-checks-new-rows.sh create mode 100644 scripts/test_privacy_guard.py diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index a88c3b0cf..caf4ac712 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -109,7 +109,7 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - vacuous_test_gate.py: a changed test file must really run and assert. — [REPORT] (--changed PATH... | --base… - vendor_drift.py: list vendored skill files in TARGET that differ from the… — TARGET [--src DIR] - wired_check.py: warn when a NEW script has no caller. — BASE HEAD [--root DIR] -- No documented CLI: _clean_union.sh, _dirty_guard.sh, _lock.sh, admission_check.py, board_common.py, bypass_guard.py, ceo_altitude_guard.py, comms_guard.py, context_nudge.py, cost_statusline.py, decision_gate.py, destructive_guard.py, destructive_guard_core.py, finding_guard.py, flake_ledger.py, freeze_hook.py, gh_rest.py, handback_cap.py, handoff_inject.py, heavy_gate.py, host_accept.py, host_installer.py, host_safety.py, landing_watchdog.py, lane_cap.py, lane_resume_nudge.py, lkg.py, outcome_claim_guard.py, peer_ack.py, perun_auto_update.py, pipe_mask_guard.py, pipeline_profile.py, plan_gate.py, pregate_parity.py, red_main.py, repeat_poll_guard.py, reply_cap.py, restore_ledger.py, scope_creep_check.py, session_brief.py, stall_guard.py, subagent_brief_lint.py, surface_check.py, terse_reply_check.py +- No documented CLI: _clean_union.sh, _dirty_guard.sh, _lock.sh, admission_check.py, board_common.py, bypass_guard.py, ceo_altitude_guard.py, comms_guard.py, context_nudge.py, cost_statusline.py, decision_gate.py, destructive_guard.py, destructive_guard_core.py, finding_guard.py, flake_ledger.py, freeze_hook.py, gh_rest.py, handback_cap.py, handoff_inject.py, heavy_gate.py, host_accept.py, host_installer.py, host_safety.py, landing_watchdog.py, lane_cap.py, lane_resume_nudge.py, lkg.py, outcome_claim_guard.py, peer_ack.py, perun_auto_update.py, pipe_mask_guard.py, pipeline_profile.py, plan_gate.py, pregate_parity.py, privacy_guard.py, red_main.py, repeat_poll_guard.py, reply_cap.py, restore_ledger.py, scope_creep_check.py, session_brief.py, stall_guard.py, subagent_brief_lint.py, surface_check.py, terse_reply_check.py ### business-ops - bizops_check.py: Deterministic arithmetic checks business-ops already states… — [input.json] (stdin when no path) diff --git a/.claude/skills/agentic-delivery/INDEX-scripts.md b/.claude/skills/agentic-delivery/INDEX-scripts.md index b198341f7..3275c72d8 100644 --- a/.claude/skills/agentic-delivery/INDEX-scripts.md +++ b/.claude/skills/agentic-delivery/INDEX-scripts.md @@ -77,6 +77,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/premise_check.py` | before handing PRs or issues to a lane or peer, say which are already done or stale. | premise_check.py N [N ...] [--repo OWNER/NAME] [--timeout S] | | `scripts/prevention_lint.py` | every prevention/action item needs an owner, a tracking id and a verifiable end state. | prevention_lint.py FILE.md | | `scripts/priority_gate.py` | no presentation-only PR while an aged P0/mechanism issue has no PR citing it. | priority_gate.py --labels-json FILE [options] priority_gate.py --repo OWNER/REPO --pr N [options] priority_gate.py --se… | +| `scripts/privacy_guard.py` | PreToolUse(Bash) hook: DENY a GitHub write whose title or body matches the repo's banlist (a write… | — | | `scripts/queue_guard.py` | single priority queue: print the next issue to pull, flag P0 inflation. | queue_guard.py --issues issues.json --priorities PRIORITIES.md [--max-unranked N] issues.json gh issue list --json numb… | | `scripts/reap_own.sh` | kill your own dev servers older than MAX_AGE_S whose cwd is under ROOT (your own lane or session tr… | reap_own.sh | | `scripts/red_main.py` | green-main alarm: every red test on the default branch needs a fix or an owner within 1h. | — | diff --git a/.claude/skills/agentic-delivery/INDEX.md b/.claude/skills/agentic-delivery/INDEX.md index 801f10afd..19740a9ee 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 -109 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. +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. diff --git a/.claude/skills/agentic-delivery/scripts/comms_guard.py b/.claude/skills/agentic-delivery/scripts/comms_guard.py index 2992f5c20..755a94d9f 100644 --- a/.claude/skills/agentic-delivery/scripts/comms_guard.py +++ b/.claude/skills/agentic-delivery/scripts/comms_guard.py @@ -34,11 +34,22 @@ REST = re.compile(r"(?:\bcurl\b|\bgh\s+api\b)[\s\S]{0,600}?\brepos/[^/\s'\"]+/[^/\s'\"]+/(pulls|issues)((?:/\d+)?(?:/(?:comments|reviews))?)(?=[\s'\"?]|$)") HEREDOC = re.compile(r"<<-?[ \t]*(['\"]?)(\w+)\1[ \t]*\n(.*?)\n[ \t]*\2[ \t]*(?=\n|\)|\"|'|$)", re.S) VALUE = re.compile(r"\"((?:[^\"\\]|\\.)*)\"|'([^']*)'|(\S+)", re.S) +QUOTED = re.compile(r"\\.|'[^']*'|\"(?:[^\"\\]|\\.)*\"", re.S) # an escape, or a complete quoted string + + +def _plain(tail, keep=None): + """`tail` with each heredoc body and each quoted string blanked to NULs, the same length so offsets line up; `keep(string)` true + spares a quoted string. Stray quotes in a heredoc body pair with nothing, and an unclosed quote hides nothing.""" + plain = list(tail) + for h in HEREDOC.finditer(tail): + plain[h.start(3):h.end(3)] = "\0" * len(h.group(3)) + return QUOTED.sub(lambda m: m.group() if m.group()[0] == "\\" or (keep and keep(m.group())) else "\0" * len(m.group()), "".join(plain)) def _flags(tail, names): - """Yield the raw text after each occurrence of a flag in `names` (regex alternation), `--flag value` or `--flag=value`.""" - for m in re.finditer(rf"(?:^|\s)(?:{names})(?:=|[ \t]+)", tail): + """Yield the raw text after each occurrence of a flag in `names` (regex alternation), `--flag value` or `--flag=value`. + A flag-like word inside a quoted string (the title "Add -b flag") or a heredoc body is not a flag.""" + for m in re.finditer(rf"(?:^|\s)(?:{names})(?:=|[ \t]+)", _plain(tail)): yield tail[m.end():] @@ -82,21 +93,30 @@ def _gh_body(tail, noun, cwd): return None -def _rest_body(tail, cwd): - for rest in _flags(tail, r"-f|-F|--field|--raw-field"): # gh api -f body=TEXT, -f 'body=TEXT', -F body=@FILE - q = re.match(r"([\"'])body=", rest) - if q: - return _value(q.group(1) + rest[q.end():], cwd) - if rest.startswith("body="): - return _file(rest[5:], cwd, tail) if rest[5:].startswith("@") else _value(rest[5:], cwd) +def _rest_field(tail, cwd, key, literal=False): + """The text of one field (`body`, `title`) a REST write sends, or None if it is absent or unreadable. gh sends `-f`/`--raw-field` + text as typed (`@jane` is a mention) and reads `-F`/`--field` `@FILE`; `literal` gives an unreadable `@FILE` as typed, not an error.""" + for names, at_file in ((r"-f|--raw-field", False), (r"-F|--field", True)): # gh api -f body=TEXT, -f 'body=TEXT', -F body=@FILE + for rest in _flags(tail, names): + q = re.match(rf"([\"']){key}=", rest) + if q or rest.startswith(key + "="): + val = _value(q.group(1) + rest[q.end():] if q else rest[len(key) + 1:], cwd) # one shell word: more arguments may follow + if not (at_file and val and val.startswith("@")): + return val + try: + return _file(val, cwd, tail) + except (OSError, ValueError): + if literal: + return val + raise for rest in _flags(tail, r"-d|--data|--data-raw|--data-binary"): # curl -d '{"body": ...}' or -d @FILE raw = _value(rest, cwd) raw = _file(raw, cwd, tail) if raw and (raw.startswith("@") or raw == "-") else raw - return json.loads(raw).get("body") if raw else None + return json.loads(raw).get(key) if raw else None for rest in _flags(tail, r"--input"): # gh api --input FILE name = _value(rest, cwd) raw = _file(name, cwd, tail) if name else None - return json.loads(raw).get("body") if raw else None + return json.loads(raw).get(key) if raw else None return None @@ -116,7 +136,7 @@ def extract(tool_name, tool_input, cwd=""): body = _gh_body(cmd[m.start():], noun, cwd) elif (m := REST.search(cmd)): kind = "pr" if m.group(1) == "pulls" and not m.group(2).endswith(("comments", "reviews")) else "message" - body = _rest_body(cmd[m.start():], cwd) + body = _rest_field(cmd[m.start():], cwd, "body") else: return None except (OSError, ValueError, AttributeError): # unreadable file, non-JSON data: nothing to lint diff --git a/.claude/skills/agentic-delivery/scripts/privacy_guard.py b/.claude/skills/agentic-delivery/scripts/privacy_guard.py new file mode 100644 index 000000000..ed2e5d95f --- /dev/null +++ b/.claude/skills/agentic-delivery/scripts/privacy_guard.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# rail: owner=agentic-delivery, why=deny a GitHub write whose title or body matches the repo banlist except into a sibling repo of the origin owner and deny a write into Perun that names the session repository, review=2027-01-30 +"""privacy_guard.py — PreToolUse(Bash) hook: DENY a GitHub write whose title or body matches the repo's banlist (a write into a sibling +repository of the session origin's owner is not scanned), and a write into the public Perun repository (SELF_REPO) that names the +repository of the session it is filed from. + +WHY: the privacy gate scans the files a repo ships, and nothing scanned the text an agent publishes through `gh` or the API, so a +public issue body that named a private repository stayed up for weeks. This hook runs the repo's own banlist over the title and +body of each GitHub write before it is sent. A project's banlist does not list the project's own name (it is all over its files), so a +field report filed from that project's session into the public Perun repository carried the name past the banlist; the own-repository +rule below closes that. + +Covers what comms_guard.py covers, through its extraction (imported, not copied): Bash `gh pr|issue|release create|edit|comment`, +and `curl` or `gh api` calls to repos///pulls or /issues. Title: `--title`/`-t`, `-f title=...`, JSON `title`. Body: +`--body`/`-b`/`--notes`, a heredoc, `$(cat FILE)`, `--body-file`/`-F FILE`, `-d`/`--input` JSON, `-f body=...`, `-F body=@FILE`. `-f` +text is scanned as typed (`@jane` is a mention); `-F` reads `@FILE`, and a file it cannot read is scanned as typed. Every write in a +command is read. A body file the same command writes (`> F`, `>> F`, `tee F`) is read from the heredoc that feeds it, not from the +disk, which holds an old copy or none; what it writes any other way (`echo`, `printf`, `cp`) is not read. Not covered: `gh pr +merge|review`, `gh issue|pr close --comment`, `gh repo edit`, `--fill`, PATCH of `issues/comments/ID`, POST of `pulls/N/comments/M/replies`, +`repos///releases`, `gh api graphql`, `curl --data-urlencode` and `--json`; a body fed to stdin by a pipe or `<` (`-F -`, +`--body-file -`; a heredoc is read); text that a shell variable or a command substitution other than `cat FILE` produces at run time; a +single-quoted value with an escaped quote inside, a `$'...'` string, `-b"text"` with no space; a relative body file after `cd DIR &&` +(read from the payload `cwd`, not DIR). + +Patterns: the actionable lines (non-blank, first non-space character not `#`) of `.banlist.txt`, then `.banlist.local.txt`, at the git +top-level of the payload `cwd`; a linked worktree lacks the gitignored local file, so the main checkout's is read instead. Each line +is an ERE run through `grep -E` over each line of the text, as `scripts/ci-gates.sh privacy` does: case-sensitive, and an invalid ERE +is skipped here (the gate fails closed on it). + +Scope: the banlist is the session repo's and runs over every write except one whose explicit target is another repository of the +session origin's owner (a sibling): a private repository's name belongs in a write into that repository. The session's own repository +(named or implicit), SELF_REPO, every other owner's repository and a session with no usable `origin` stay scanned: a private target +cannot be told from a public one without a network call, and a wrong deny is cheaper than a leak. Residuals: a public sibling in the +origin's organisation is not scanned, and another owner's private repository is, so a legitimate name in it is denied. The target is +`-R`/`--repo OWNER/REPO` (quoted, or with a `github.com/` host), a github.com URL, or a `repos/OWNER/REPO/(issues|pulls)` path, read from +the write's own region only: from the write to the next gh or curl write or the end of its command line (`&&`, `||`, `;`, `|`, a +newline, a `#` comment), with each heredoc body, each quoted string that holds a space and a one-word title or body blanked first, so +text that only mentions a repository (a body quoting `-R acme/other`, a later `echo`) sets no target. A `.git` suffix is dropped. A +write that names several repositories is skipped only when every one is a sibling; a target set by `GH_REPO` is not seen. + +Own-repository rule: a write that targets SELF_REPO (read as above) from a session whose `origin` remote names another repository is +denied when its title or a body line names that repository, as `owner/name` or as the bare name (case-insensitive, whole word; a short +plain name is skipped, see `_own_names`). No network: a missing `origin`, an unparsable URL or a session inside SELF_REPO has no such +rule, and a target set by `GH_REPO` is not seen. + +Output: on a hit, exit 2 and a stderr reason that names each hit as `title` or `body line N`, a banlist hit adding the pattern's 1-based +number among the actionable lines above. The matched text and the repository's name are never printed. Otherwise exit 0 and print nothing. +FAIL OPEN: a non-Bash tool, no git repo, no banlist, no title or body, an unreadable body file (a readable title is still checked), +bad stdin, a missing `grep` or `git`, or any error allows. Register it WITHOUT `|| true`, which would turn the deny into an allow. Opt +out for a session or a project with `PERUN_PRIVACY_GUARD=off` (settings `env`; a `VAR=off` prefix on the command never reaches a hook). +Side effects: read-only `git rev-parse` and `git remote get-url`, `grep` and file reads; no network. Test: scripts/test_privacy_guard.py. +""" +import json +import os +import re +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +SHOWN = 5 # hits named in the deny reason +SELF_REPO = "protocol/perun" # lower case; same constant as contribution/scripts/prefile_check.sh +# A repository a write names: `-R`/`--repo OWNER/REPO` (quoted, or with a github.com host), a github.com URL, or a REST path `repos/OWNER/REPO/issues`. +TARGETS = re.compile(r"(?:(?= 8 else []) + return re.compile(rf"(?>?|\btee\s+(?:-\S+\s+)*)\s*[\"']?{re.escape(path)}[\"']?(?=[\s;&|)]|$)", cmd)} + if not lines: + return disk(path, cwd) + return "\n".join(h.group(3) for h in G.HEREDOC.finditer(cmd) if cmd.rfind("\n", 0, h.start()) in lines) # the heredoc opens on the line of the redirect: `cat > F <<'EOF'` + + G._read = read # ponytail: patched around the extraction so it stays imported, not copied (one hook process, one command) + try: + writes = sorted([*G.GH.finditer(cmd), *G.REST.finditer(cmd)], key=lambda m: m.start()) + for i, m in enumerate(writes): + tail = cmd[m.start():] + if m.re is G.GH: + title = _try(lambda: next((G._value(r, cwd) for r in G._flags(tail, r"--title|-t")), None)) + body = _try(G._gh_body, tail, m.group(1), cwd) + else: + title, body = _try(G._rest_field, tail, cwd, "title", True), _try(G._rest_field, tail, cwd, "body", True) + end = writes[i + 1].start() - m.start() if i + 1 < len(writes) else len(tail) + out.append((_targets(G, tail, m.end() - m.start(), end, (title, body)), title, body)) + finally: + G._read = disk + return out + + +def _named(hits): + """The first SHOWN hits joined by `; `, then how many more there are.""" + return "; ".join(hits[:SHOWN]) + (f"; {len(hits) - SHOWN} more" if len(hits) > SHOWN else "") + + +def verdict(cmd, cwd): + """(exit code, stderr text) for one Bash command: 2 and a reason without the matched text or the session repo's name when a title or + body line matches the banlist (a write to a sibling repository of the origin's owner is not scanned) or, in a write to SELF_REPO, + names the session's own repository.""" + if not re.search(r"\b(?:gh|curl)\b", cmd): + return 0, "" + writes = [w for w in outgoing(cmd, cwd) if w[1] or w[2]] + if not writes: + return 0, "" + pats = patterns(cwd) + origin = _origin(cwd) if any(w[0] for w in writes) else None # no usable origin: every write is scanned and there is no own-repository rule + names = _own_names(cwd, origin) if origin else None + ban, own = [], [] + for targets, title, body in writes: + if not (origin and targets and all(_sibling(t, origin) for t in targets)): + ban += [("title", i) for _, i in scan(title, pats)] + [(f"body line {n}", i) for n, i in scan(body, pats)] + if names and SELF_REPO in targets: + own += (["title"] if names.search(title) else []) + [f"body line {n}" for n, ln in enumerate(body.split("\n"), 1) if names.search(ln)] + msg = [] + if ban: + msg.append("Blocked: the title or body of this GitHub write matches the repo banlist (matched text withheld): " + f"{_named([f'{where} (pattern {i})' for where, i in dict.fromkeys(ban)])}. Remove or reword that text and send it again. " + "Patterns are numbered over the actionable lines of .banlist.txt, then .banlist.local.txt.") + if own: + msg.append(f"Blocked: this write to {SELF_REPO} names the session's own repository (name withheld): {_named(list(dict.fromkeys(own)))}. " + "Reword that text without the repository's name and send it again.") + return (2, " ".join(msg)) if msg else (0, "") + + +def main(): + if os.environ.get("PERUN_PRIVACY_GUARD", "").lower() in ("off", "0", "false"): + return 0 + try: + data = json.load(sys.stdin) + if data.get("tool_name") != "Bash": + return 0 + code, msg = verdict(str((data.get("tool_input") or {}).get("command") or ""), str(data.get("cwd") or "")) + except Exception: # fail open: a broken privacy hook must never block work + return 0 + if msg: + print(msg, file=sys.stderr) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/agentic-delivery/templates/operating-layer.settings.json b/.claude/skills/agentic-delivery/templates/operating-layer.settings.json index 9e2068146..a86c246e3 100644 --- a/.claude/skills/agentic-delivery/templates/operating-layer.settings.json +++ b/.claude/skills/agentic-delivery/templates/operating-layer.settings.json @@ -99,6 +99,16 @@ } ] }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "timeout": 10, + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/skills/agentic-delivery/scripts/privacy_guard.py\"" + } + ] + }, { "matcher": "Bash", "hooks": [ diff --git a/.claude/skills/deep-code-review/INDEX.md b/.claude/skills/deep-code-review/INDEX.md index 4f88105bc..4194e55d4 100644 --- a/.claude/skills/deep-code-review/INDEX.md +++ b/.claude/skills/deep-code-review/INDEX.md @@ -98,7 +98,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/method-diff.md` | the review scope is a DIFF or FILE (a large or blast-radius-flagged DIFF, a fix that is itself the… | 1968 | | `references/method-lenses.md` | method.md routes here: a call site may bypass or re-derive a shared helper, component or predicate,… | 1935 | | `references/method-report.md` | writing the Phase 5 report (delivery shape, out-of-tree vs in-repo, ledger and claim reconciliation… | 2292 | -| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11099 | +| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11175 | | `references/method.md` | executing a review: after stating SCOPE / START_SHA / COVERAGE_LEDGER, work the phases in order. | 7484 | | `references/migration-parity.md` | the reference you are matching is a prototype / mockup / design export (a static page, a design-too… | 7610 | | `references/mobile-appsec.md` | the target is (or ships) a native mobile client — an iOS (Swift / Objective-C) or Android (Kotlin /… | 6478 | diff --git a/.claude/skills/deep-code-review/references/method-situational.md b/.claude/skills/deep-code-review/references/method-situational.md index b23f478ce..9719be861 100644 --- a/.claude/skills/deep-code-review/references/method-situational.md +++ b/.claude/skills/deep-code-review/references/method-situational.md @@ -9,7 +9,7 @@ Read this when `method.md` routes here (its "Situational Phase 1 checks" bullet gitleaks, trufflehog (offline, `--no-verification`: unverified; a row is detector, file, line, never the secret), semgrep with the repo's rules or else the bundled taint rules plus p/default (`REVIEW_SEMGREP_REGISTRY=0` drops the registry pack), trivy and osv-scanner offline, osv online with `REVIEW_OSV=1`) on the changed files and prints findings JSON; `--history` scans commit history, `--tests` adds the declared `REVIEW_TEST_CMD`; stderr ends with a one-line ran/skipped summary. Its findings are leads: verify each against the code before reporting, report a `not run` line or `not-checked` row as unchecked surface, and never read an empty - list as clean (analyzers miss fail-open and logic defects). Its gitleaks pass uses `templates/gitleaks.toml` + list as clean (analyzers miss fail-open and logic defects). In diff mode every row carries `"new": true|false` (true when its file and line are on a line the diff adds; line 0 and file-level rows are false; `--full` and `--history` omit the key): verify new rows first, and report a pre-existing row in a touched file only when the diff makes it reachable or worse. Its gitleaks pass uses `templates/gitleaks.toml` (default rules plus a skip list for `.history/`, `fixtures/`, `testdata/` and generated registry JSON and lockfiles; the skip count is printed) when the repo has no `.gitleaks.toml`. An optional repo-root `.gitleaks-allow` (`rule path-glob sha256-of-secret` per line, format in the template) drops known false positives by content hash, so it survives history rewrites; it is read from the base ref only, and a malformed list is ignored with a warning. **Verify or drop every scanner lead:** open the cited line, confirm the code is reachable and, for a secret, that a real value is present (a name like `api_key` or `*_key` with no value, a placeholder or a fixture is not a secret); drop what fails; report diff --git a/.claude/skills/deep-code-review/scripts/review_checks.sh b/.claude/skills/deep-code-review/scripts/review_checks.sh index 9a18bce43..6675afe7b 100755 --- a/.claude/skills/deep-code-review/scripts/review_checks.sh +++ b/.claude/skills/deep-code-review/scripts/review_checks.sh @@ -3,6 +3,7 @@ # changed since REF (default origin/main, compared as REF...HEAD) and print findings as a JSON array on # stdout: [{"file","line","rule","message","text","tool"}]. `text` ("rule: message") is the key # scripts/score_review.py matches on. Findings are LEADS: verify each against the code before reporting. +# Diff mode (not --full or --history) adds "new": true|false to every row: its file+line is on a line REF...HEAD adds (rename-aware; line 0 and file-level rows are false). No row is ever dropped. # # Tools (linters, gitleaks, trufflehog, osv-scanner, semgrep, trivy): see references/method-situational.md. Each runs only when # installed and only on matching changed files. Secrets are redacted and never printed; trufflehog runs offline (`--no-verification --no-update`) over the changed files (every tracked file under --full), @@ -846,7 +847,10 @@ if [ "$tests" = 1 ]; then fi r=; for t in $ran; do case " $failed " in *" ${t%%(*} "*) ;; *) r+=" $t" ;; esac; done; ran=$r -REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" <<'PY' +# "new" flags, diff mode only (a --full or --history row may sit on a line no diff added). Flags are pinned so user git config cannot change the parse: +# --inter-hunk-context=0 and --no-color keep hunk counts and headers exact, the prefixes fix the "+++ b/" name, and -a gives a text file with a NUL byte (invisible-chars rows) hunks instead of "Binary files differ". +ud=; if [ "$wr" = 0 ] && [ "$hist" = 0 ]; then ud=$td/udiff; git -c core.quotePath=false diff -U0 --inter-hunk-context=0 -M -a --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ "$base"...HEAD >"$ud" || ud=; fi +REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" "$ud" <<'PY' import json, os, re, shlex, sys, time rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1], encoding="utf-8", errors="replace")] rows = [t for i, t in enumerate(rows) if t[3:4] != ["not-checked"] or t not in rows[:i]] # a tool failing on N files is one not-checked row @@ -882,6 +886,17 @@ for t in data: k = key(t) if not k or len(groups[k]) <= 3: out.append(row(t)) elif k not in done: done.add(k); out.append(summ(k, groups[k])) +ud = sys.argv[11] # `git diff -U0` of BASE...HEAD; "" under --full/--history (no "new" key) +if ud: + adds, cur, inh = {}, None, False # adds: file -> [(first line, count)] of added lines; cur: the file whose hunks are being read; inh: past its first hunk header, where a "+++ " line is content + for l in open(ud, encoding="utf-8", errors="replace"): + if l.startswith("diff --git "): cur, inh = None, False + elif l.startswith("@@ "): + inh = True; m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", l) + if cur and m: adds[cur].append((int(m[1]), 1 if m[2] is None else int(m[2]))) # "+N" is one line, "+N,0" none + elif not inh and l.startswith("+++ b/"): cur = l[6:].rstrip("\n").split("\t")[0]; adds[cur] = [] # git adds a tab after a name with a space; a quoted name or /dev/null leaves cur unset + for r in out: # file-level rows (line 0, no file) are never new; every other path is read relative to the repo root, as the tools print it ("./x", absolute) + r["new"] = r["line"] > 0 and r["file"] not in ("", "-") and any(a <= r["line"] < a + c for a, c in adds.get(os.path.relpath(r["file"]), ())) print(json.dumps(out, indent=1)) n = len(out) dg, w1 = sys.argv[5].split(), sys.argv[6].split() # lease full: "1 worker" only for tools whose single-worker flag ran; "nice 19" only when nice worked @@ -889,6 +904,7 @@ def lab(t): b = t.split("(")[0] return t + (f" (degraded: lease full{', nice 19' if sys.argv[7] == '1' else ''}{', 1 worker' if b in w1 else ''})" if b in dg else "") print(f"review_checks: {n} findings; {sys.argv[4]} files excluded; analyzers ran: {', '.join(lab(t) for t in sys.argv[2].split()) or 'none'}; skipped (not installed): {', '.join(sys.argv[3].split()) or 'none'}", file=sys.stderr) +if ud: print(f"review_checks: {sum(r['new'] for r in out)} of {n} rows are on lines this diff adds", file=sys.stderr) # "ran X / wired Y": wired = scanners with work in this run (their files matched); fixable = wired and either missing or running without a cache a provision warms. # argv[10] (shellcheck, ruff/pyflakes, gitleaks-history) is wired too, but provision_tools.sh cannot install it, so it is never fixable. wired = sys.argv[8].split() diff --git a/SHA256SUMS b/SHA256SUMS index 563429640..37eb223a4 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -18,8 +18,8 @@ ef9e423362b12d0dd73c8e165faf32e9f53c4d4ac215b57c2e34002a88713838 .claude/skills 71def6259e7016301932ac474dd4b7afea29349a8dd0b6284d74b5e9ebd16936 .claude/skills/agentic-ceo/scripts/token_ratchet.py 372f7d86157392ebfab627f6a05a892e4dd3297db9b8bfc6f404759169c7500c .claude/skills/agentic-ceo/scripts/token_report.py 9521b057ff3db92d1e60923ebd88170713914f7e235ceb45717eb39419daf811 .claude/skills/agentic-ceo/scripts/weekly_receipt.py -001b7b0a350ff5b7d4e5b85ca2bc6060514410377461a4def7579a92ab7f187a .claude/skills/agentic-delivery/INDEX-scripts.md -29ffa479167c28a166aa0fe2cf189bb12335186256e391eccec68208fd5b36a0 .claude/skills/agentic-delivery/INDEX.md +7e2179751e9dd2fb371b3e9fb01fca987d69605af1e8e6e609ff9b0d0abb384b .claude/skills/agentic-delivery/INDEX-scripts.md +47fff02dcb4401d5a9b42b2b3b7f7c123125eece55e7f9adee8cfe488c70a146 .claude/skills/agentic-delivery/INDEX.md 04982647fd4125802d46d991ca23ce7fa648a6b2b71074ebbf2c71e6d483619e .claude/skills/agentic-delivery/SKILL.md 73e93e5ff760a52ccc7044d782e1db000a421b043567d8de6ed00a922ffc5959 .claude/skills/agentic-delivery/VERSION 94144c101f82399eb6c18f688321a00e1c195dd0ab334f3a5daa2543b40ff9f5 .claude/skills/agentic-delivery/evals/evals.json @@ -68,7 +68,7 @@ a6fc31cf16e20c1b3107b832c1c65b766984199fd64d5cc0937df88951183e69 .claude/skills 0193bc266dd2726b6b370e7d733fe7cdda33ebdf650748d561c9e6f8a311b8e0 .claude/skills/agentic-delivery/scripts/claim_probe.py 03af48a629587a7d5d826a84420373bd3bfce9f96097a1eea09bb3656d2b3359 .claude/skills/agentic-delivery/scripts/clean_finished.sh 264713d1667dd51c08ab3646450a5da198eda83f28f8f5705275e911cb3ee9b8 .claude/skills/agentic-delivery/scripts/clock_lint.py -3b6d6832f55540575c6a6e9840670c255b6e7d4ec25ab22ce5058f0b7761c523 .claude/skills/agentic-delivery/scripts/comms_guard.py +8aeadfe6f80dc66cb2ac5984c48d407e23e01679b045e263ec5350112febec76 .claude/skills/agentic-delivery/scripts/comms_guard.py 825a6881cf342d50f8ad8bb4dd78eb2403e8cfe1dec94c64adf0ed61f55fcd44 .claude/skills/agentic-delivery/scripts/context_nudge.py bb340c0138e40e403e2832f11b59199cf9e3427ffab9e18fcffd69f0e227fd6a .claude/skills/agentic-delivery/scripts/cost_statusline.py 703423ad6921f7dd2ca3f41c6f72adbcd16a07da3df18f205f11a07d479c7207 .claude/skills/agentic-delivery/scripts/decision_gate.py @@ -126,6 +126,7 @@ a9235428a933257a45cc486a58dffe4f8ec5a398311937c887066aa0e0698162 .claude/skills 6543ba858ea5e599d12ac8b7fc4cb56919d28ea66c521d887f574bb5d1139c11 .claude/skills/agentic-delivery/scripts/premise_check.py 5e6bf88353f68be4b012cd44742e8bba94db5fd17b246e73b682b9d70577540b .claude/skills/agentic-delivery/scripts/prevention_lint.py 3e3045c3d4a695927970cf767569cbf271b4ae30ddd11eb2b2defab880c485bc .claude/skills/agentic-delivery/scripts/priority_gate.py +19223509022e7a9c3133c1784643bea0f2bcd010a06f9ab4ef7e6eaf10601710 .claude/skills/agentic-delivery/scripts/privacy_guard.py 9eaf96f25ab91db9fde36ee43bd2a85f0fb422107f92cecfada96aafe1b62af3 .claude/skills/agentic-delivery/scripts/queue_guard.py df84be6c5c868d26b90c34ba720e87407f63bc259dbabfb30ae41789dfb5da63 .claude/skills/agentic-delivery/scripts/reap_own.sh d9135da4b8af3e2aacc5de5edb28843d6370ced0185860f49afc5eca9918987f .claude/skills/agentic-delivery/scripts/red_main.py @@ -169,7 +170,7 @@ ac7eb14ad5aa0add90a39d2f6d1f8ab1d53763b4912e13916f788cdd30a1d0c2 .claude/skills c3ef87aa65dec1f63df5f6b1aadc3a1e5c322ed7288a5dbd3b7617d56c12adbb .claude/skills/agentic-delivery/templates/loops/cleanup.md b956a4767c55179e732c5e34581abfd07b285dd73aa030ad9119005cb8e2d0a2 .claude/skills/agentic-delivery/templates/loops/coordinator.md 7f5e043836dca83534623994d97de0da88caec290e434d0c7a1a52cf2345f05b .claude/skills/agentic-delivery/templates/loops/peer.md -59507ff96d89724b022dee7234906c0af218655dbbdabd6f4f41493effc82607 .claude/skills/agentic-delivery/templates/operating-layer.settings.json +7fa1d9aa98581763f73f661b2ae46a5246ea6ecef54ea717c34be2603fea0ff7 .claude/skills/agentic-delivery/templates/operating-layer.settings.json 024c151cdc8b5175a25ebe44cf4cbb86539ae8d9f90e03b7e2ac359481841681 .claude/skills/agentic-delivery/templates/review-tiers.tsv 0a250e0345c762cbcd7ac018c161bc9a11b405fed644c936379fb74881cc5025 .claude/skills/agentic-delivery/templates/terse-replies.settings.json 986cd7d948bb0aea7abbaa89931756e68b63f48d6fc0fccb00a8e070e618ec6d .claude/skills/business-ops/INDEX.md @@ -204,7 +205,7 @@ d98f3e4380da3496061ba63be7beebe75017ab4ee1734eda9a3e798e19007b0e .claude/skills 92d22cc5d0a60ba6bdb1f4a9b597823dec5b8412cec09a5be6cf93b3973f4226 .claude/skills/contribution/scripts/prefile_check.sh 18f20db537ab784460ad33cf218f7a01ac0a172fc38413f14c0e19e1ce6821cc .claude/skills/contribution/scripts/share_learning.py 63e7d07ffa54b224a83abee280456f6a24dc8222b9b55f6e249b10ea0c7f5afc .claude/skills/deep-code-review/INDEX-scripts.md -848ddf8c618da617b98bbe76808e775e6ef4f25ccc71954a868a093411d5e9d4 .claude/skills/deep-code-review/INDEX.md +3145a0f11e8ecc8b510ae32e7de8eff9bea24e1e9bf46ed662221b7b4d386de2 .claude/skills/deep-code-review/INDEX.md a44ec08c8bd85b4adb4ce9c18e01e8c01a500f6efaceaf4bc79e2c396f4393d3 .claude/skills/deep-code-review/SKILL.md 73e93e5ff760a52ccc7044d782e1db000a421b043567d8de6ed00a922ffc5959 .claude/skills/deep-code-review/VERSION b97d95bc93cc23a8bc689e2e23db261a138367bcbc69d2f8a0452d5c2c6dfb68 .claude/skills/deep-code-review/evals/evals.json @@ -301,7 +302,7 @@ cfdc3609c63b7669dd0ddcab3546dbae6036e5214668802acfb0cfc9117a8f25 .claude/skills 712ff4adad24308045101ee386340a61dd80230ddcbdedc30603d18b412378d3 .claude/skills/deep-code-review/references/method-diff.md 1c428659366bb21fad34ab66f42390410dbf5191d97c07ca40db8b0476081fa2 .claude/skills/deep-code-review/references/method-lenses.md 43ce8532a5f85d5784bcfd1d052ed6d7e832902602d2f7e5e8c4fafebef0b8e2 .claude/skills/deep-code-review/references/method-report.md -77b2176d240a480e43fa11d93098e551ff9af49c25459a0fb22d07b64b645e02 .claude/skills/deep-code-review/references/method-situational.md +9a97de6c253d9d39bfa108c0608dfc6ff820abbad64e9d5b6362695d472ea8d4 .claude/skills/deep-code-review/references/method-situational.md fd354313f768db8a13a99e4012379a2c75f804a120c8e2de8556a8cac3981742 .claude/skills/deep-code-review/references/method.md 5fd086a47f860ab2b946d9409d1e1de03b1338cf832d5f1539fb3dfe63d85001 .claude/skills/deep-code-review/references/migration-parity.md 17838a58489145ecb8a466737ae5e12ac37e98260d335d160aff95d3d5354804 .claude/skills/deep-code-review/references/mobile-appsec.md @@ -396,7 +397,7 @@ e2903a566deee3423cc377b000475b30d6698ccebd640cb62b86b6d2001c4822 .claude/skills 5d6910164ddf6dfd4ed62dfc365a2f615f49dc07878ed3d1f881aa9518e481c2 .claude/skills/deep-code-review/scripts/post_review.sh b01cae69d80ca560cc414c1c70ef0ddfdbb910da5b68df4e9d46cf0daf3f8b16 .claude/skills/deep-code-review/scripts/provision_tools.sh f1108e2700864195a239744b319b1d04953879589f8a2b83708bc45a080e3d86 .claude/skills/deep-code-review/scripts/reaper_lint.py -4e7ff3d05f56d8e81864ba434bf4e9adfa3dc4243cef01a4d06bf0e0260d6314 .claude/skills/deep-code-review/scripts/review_checks.sh +6315b222c3993981e09b9b608502b71b055cbee6ef4819b3e5dc6293029b0c7d .claude/skills/deep-code-review/scripts/review_checks.sh 8c87c02a050a975e453b52ecacbaf01481c7134466a9c67feaf409d22e36d9f7 .claude/skills/deep-code-review/scripts/review_feedback.py 859c67d6a6a781c873f47d5662d403da2321da53e6994ca3d0ccdbf74afd39ac .claude/skills/deep-code-review/scripts/score_review.py e9dfa337ad16d0022a3b7a87e0ba49acb5c2c9c0a5ee24396ff80523f2f8fa1a .claude/skills/deep-code-review/scripts/security_verify.py diff --git a/changelog.d/guard-registry.md b/changelog.d/guard-registry.md new file mode 100644 index 000000000..6e099946c --- /dev/null +++ b/changelog.d/guard-registry.md @@ -0,0 +1,7 @@ +### Added +- `scripts/guard-registry.tsv`: one row per hook a shipped template runs or `install.sh` writes and per `ci-gates.sh` gate, with columns `id`, `owner`, `reason`, `review-by` and an optional fire log path. A guard is a hook script a shipped `.claude/skills/*/templates/*.settings.json` runs (`hook:/