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'"