Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions scripts/git_hooks/blocked-terms.sha256
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions scripts/git_hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -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"
159 changes: 159 additions & 0 deletions scripts/git_hooks/leak_guard.py
Original file line number Diff line number Diff line change
@@ -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 <file> 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))
5 changes: 5 additions & 0 deletions scripts/git_hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions scripts/install-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -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'"
Loading