From 6da226443dfb56a0644673e43604e39ef76d30ca Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Sun, 16 Aug 2026 15:17:16 -0700 Subject: [PATCH] Add a commit hook that keeps configured terms out of published text This repository is public, and published git text is not reliably retractable. This adds a fast local guard so a configured term cannot reach a commit message, a staged path, or an added line. - `scripts/git_hooks/commit-msg` scans the commit message. - `scripts/git_hooks/pre-commit` scans staged paths and added lines. - `scripts/git_hooks/leak_guard.py` is the shared scanner. - `scripts/install-git-hooks.sh` points `core.hooksPath` at the tracked directory, so the guard is version-controlled and shared rather than living untracked in `.git/hooks`. Run once per clone. Terms are stored as SHA-256 hashes in `blocked-terms.sha256`, never as plaintext: a readable denylist in a public repo would defeat its own purpose. Hashing resists casual grep and search indexing; it is not meant to withstand a dictionary attack. `--add-term` prompts for the term instead of taking argv, so it never lands in shell history. Matching is on whole alphanumeric tokens of the lowercased text, which covers the realistic shapes (prose, paths, `org/repo#123`, CamelCase run together) while a term buried mid-word is deliberately out of scope, to keep false positives near zero. The guard is regex-only and instant. It does not run tests -- that stays the agent's responsibility, per the existing policy, and CLAUDE.md is updated so the note about hooks stays accurate. Hooks cannot see PR bodies or issue comments, since those never pass through git. `leak_guard.py --stdin` is provided so that prose can be checked before it is published. Verified: blocks a configured term in a commit message and in a staged file, passes clean text, does not false-positive on similar-looking words, and `--no-verify` still bypasses. --- CLAUDE.md | 24 +++- scripts/git_hooks/blocked-terms.sha256 | 8 ++ scripts/git_hooks/commit-msg | 4 + scripts/git_hooks/leak_guard.py | 159 +++++++++++++++++++++++++ scripts/git_hooks/pre-commit | 5 + scripts/install-git-hooks.sh | 14 +++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 scripts/git_hooks/blocked-terms.sha256 create mode 100755 scripts/git_hooks/commit-msg create mode 100755 scripts/git_hooks/leak_guard.py create mode 100755 scripts/git_hooks/pre-commit create mode 100644 scripts/install-git-hooks.sh diff --git a/CLAUDE.md b/CLAUDE.md index b89ae3dc..a0362077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,8 +139,8 @@ every edit; target the code you touched. Run the full suite only before opening/ whole `AgentHubCore` xcodebuild suite. Subsets: `./scripts/test.sh core` / `packages`. This is the full local gate. **CI (`.github/workflows/test.yml`) gates on the fast packages only** — the `AgentHubCore` suite is run locally, not in CI (slow to compile + timing-flaky on CI's older - Xcode; see `TestQuarantine.md`), so running it locally on your changes is essential. There is - intentionally **no** git pre-commit/pre-push hook — running tests is the agent's responsibility. + Xcode; see `TestQuarantine.md`), so running it locally on your changes is essential. No git hook + runs tests — that stays the agent's responsibility. The only hooks are the fast leak guard below. - The `AgentHubCore` tests run via the shared `AgentHubCore-Tests` scheme, driven **from the package dir** (`cd app/modules/AgentHubCore`), with per-test timeouts. `swift test` on `AgentHubCore` does **not** work (CodeEditSymbols xcassets); use the script / xcodebuild. The app scheme @@ -407,3 +407,23 @@ The short version: GitHub observation is a shared actor service in `AgentHubGitH ## Git Commits - Never add "Co-Authored-By: Claude" or any Claude co-author line + +### Leak guard (this repo is public) + +`scripts/git_hooks/` holds a `commit-msg` + `pre-commit` pair that blocks configured terms — +names that should not appear in public git text — from reaching commit messages, staged +paths, or added lines. Run `./scripts/install-git-hooks.sh` once per +clone; it sets `core.hooksPath`. The check is regex-only and instant — never add test runs to it. + +- Terms live in `scripts/git_hooks/blocked-terms.sha256` as **SHA-256 hashes, never plaintext**: a + readable denylist in a public repo would defeat its own purpose. Add one with + `scripts/git_hooks/leak_guard.py --add-term`, which prompts instead of taking argv so the term + stays out of shell history. +- Matching is on whole alphanumeric tokens of the lowercased text, so for a term `acme`, + `acme/bar#123`, `AcmeUI`, and `acme.yaml` all match; a term buried mid-word does not. +- **Hooks cannot see PR bodies or issue comments** — `gh pr create --body` bypasses git entirely, + so pipe that prose through the guard first: + `scripts/git_hooks/leak_guard.py --stdin < body.md`. +- Describe external or third-party sources generically. Never name companies, private trackers, + internal product names, or individuals in anything published. +- `git commit --no-verify` bypasses the guard. Only for a reviewed, deliberate exception. diff --git a/scripts/git_hooks/blocked-terms.sha256 b/scripts/git_hooks/blocked-terms.sha256 new file mode 100644 index 00000000..fe91d82b --- /dev/null +++ b/scripts/git_hooks/blocked-terms.sha256 @@ -0,0 +1,8 @@ +# SHA-256 of blocked terms, one per line. Plaintext is deliberately +# absent: this file lives in a PUBLIC repo, so listing the words here +# would leak exactly what the guard protects. +# Add a term with: scripts/git_hooks/leak_guard.py --add-term +ab7d0a36ecbe10c286f92a49bffb57ed6bf5bc6c3a3e15967f3ea8d028316969 +94adaa65c14b2425a09a40a08075dca90857505a6f5d239cfa07ecc48a6904e9 +3dc86300df42b52795a071e2474393f3adedc7052e7e775037c3dcb2b98dffb3 +cf743eed826021848763c51e53992d6b741845a8730ac797ec60b1ea4dde2715 diff --git a/scripts/git_hooks/commit-msg b/scripts/git_hooks/commit-msg new file mode 100755 index 00000000..b4f5ad8e --- /dev/null +++ b/scripts/git_hooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +# Blocks commit messages that mention internal names. See leak_guard.py. +# Bypass (intentional, reviewed): git commit --no-verify +exec python3 "$(dirname "$0")/leak_guard.py" --message "$1" diff --git a/scripts/git_hooks/leak_guard.py b/scripts/git_hooks/leak_guard.py new file mode 100755 index 00000000..6510e5ce --- /dev/null +++ b/scripts/git_hooks/leak_guard.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Blocks configured terms from reaching published git text. + +AgentHub is public, and published git text is not reliably retractable, so +the practical control is to keep a configured term from being committed in +the first place. + +Terms are stored as SHA-256 hashes in `blocked-terms.sha256`, never as +plaintext -- a readable denylist in a public repo would defeat its own +purpose. Hashing resists casual grep and search indexing; it is not meant to +withstand a dictionary attack. + +Matching is on whole alphanumeric tokens of the lowercased text, so for a +configured term `acme`, the strings `AcmeUI`, `acme/apps#123`, and +`acme.yaml` all match, while a term buried inside a larger word +(`myacmething`) does not. Examples here are unrelated placeholders: this file +is public, so it must not hint at the words it blocks. + +Usage: + leak_guard.py --message scan a commit message (commit-msg hook) + leak_guard.py --staged scan staged content + paths (pre-commit hook) + leak_guard.py --stdin scan arbitrary text, e.g. a PR body + leak_guard.py --add-term add a term, read from a prompt (not argv, + so the term never lands in shell history) +""" + +from __future__ import annotations + +import hashlib +import pathlib +import re +import subprocess +import sys + +HERE = pathlib.Path(__file__).resolve().parent +DENYLIST = HERE / "blocked-terms.sha256" + +TOKEN_RE = re.compile(r"[a-z0-9]+") +# Commit-message comment lines are stripped by git and never persist. +COMMENT_PREFIX = "#" + + +def load_hashes() -> set[str]: + if not DENYLIST.exists(): + return set() + hashes = set() + for line in DENYLIST.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + hashes.add(line.split()[0].lower()) + return hashes + + +def digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def normalize(term: str) -> str: + """Reduce a term to the token form the scanner will see.""" + return "".join(TOKEN_RE.findall(term.lower())) + + +def scan(text: str, hashes: set[str]) -> set[str]: + if not hashes: + return set() + return {tok for tok in set(TOKEN_RE.findall(text.lower())) if digest(tok) in hashes} + + +def report(hits: set[str], where: str) -> int: + bar = "=" * 68 + print(f"\n{bar}", file=sys.stderr) + print(" BLOCKED: internal reference detected in " + where, file=sys.stderr) + print(bar, file=sys.stderr) + print("\n Matched: " + ", ".join(sorted(hits)), file=sys.stderr) + print( + "\n This repository is public and published git text is not reliably\n" + " retractable. Rewrite the text without this term -- refer to the\n" + " subject generically instead.\n\n" + " Intentional and reviewed? Bypass with: git commit --no-verify\n", + file=sys.stderr, + ) + return 1 + + +def staged_text() -> str: + """Staged file contents plus the paths themselves.""" + names = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], + capture_output=True, text=True, check=False, + ).stdout + # Added lines only: pre-existing history is out of scope for a commit guard. + diff = subprocess.run( + ["git", "diff", "--cached", "--unified=0", "--diff-filter=ACMR"], + capture_output=True, text=True, check=False, + ).stdout + added = "\n".join( + line[1:] for line in diff.splitlines() + if line.startswith("+") and not line.startswith("+++") + ) + return names + "\n" + added + + +def add_term() -> int: + try: + term = input("Term to block (not echoed to shell history): ").strip() + except (EOFError, KeyboardInterrupt): + print("\naborted", file=sys.stderr) + return 1 + token = normalize(term) + if not token: + print("error: term has no alphanumeric content", file=sys.stderr) + return 1 + digested = digest(token) + if digested in load_hashes(): + print("already blocked -- no change") + return 0 + with DENYLIST.open("a", encoding="utf-8") as handle: + handle.write(f"{digested}\n") + print(f"added ({len(token)}-char token). Commit blocked-terms.sha256 to share it.") + return 0 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print(__doc__, file=sys.stderr) + return 2 + + mode = argv[1] + if mode == "--add-term": + return add_term() + + hashes = load_hashes() + + if mode == "--message": + if len(argv) < 3: + print("error: --message needs a file path", file=sys.stderr) + return 2 + raw = pathlib.Path(argv[2]).read_text(encoding="utf-8", errors="replace") + body = "\n".join( + line for line in raw.splitlines() if not line.startswith(COMMENT_PREFIX) + ) + hits = scan(body, hashes) + return report(hits, "the commit message") if hits else 0 + + if mode == "--staged": + hits = scan(staged_text(), hashes) + return report(hits, "staged changes") if hits else 0 + + if mode == "--stdin": + hits = scan(sys.stdin.read(), hashes) + return report(hits, "the provided text") if hits else 0 + + print(f"error: unknown mode {mode}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/git_hooks/pre-commit b/scripts/git_hooks/pre-commit new file mode 100755 index 00000000..84713e05 --- /dev/null +++ b/scripts/git_hooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Scans staged paths and added lines for internal names. Regex only -- this +# hook stays instant. Tests are deliberately NOT run here; see CLAUDE.md. +# Bypass (intentional, reviewed): git commit --no-verify +exec python3 "$(dirname "$0")/leak_guard.py" --staged diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 00000000..916c5aeb --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Points this clone's hooks at the tracked scripts/git_hooks directory, so the +# leak guard is version-controlled and shared instead of living untracked in +# .git/hooks. Safe to re-run. Run once per clone (and per new worktree parent). +set -eu + +root=$(git rev-parse --show-toplevel) +cd "$root" + +chmod +x scripts/git_hooks/commit-msg scripts/git_hooks/pre-commit scripts/git_hooks/leak_guard.py +git config core.hooksPath scripts/git_hooks + +echo "hooks installed -> $(git config --get core.hooksPath)" +echo "verify: scripts/git_hooks/leak_guard.py --stdin <<< 'some text'"