Leak guards
Install gitleaks and shellcheck
On the Mac:
brew install gitleaks shellcheckOn the Spark, Ubuntu’s archive copy of gitleaks is 8.16 — too old for the hooks, which need 8.19 or later. Download the pinned release instead, verify it, and install it to /usr/local/bin, which comes first on PATH in every shell, so it wins over the archive copy. The block works in a temporary directory, so nothing lands in your clone, and each step runs only if the one before it succeeded, so a failed checksum stops the install:
cd "$(mktemp -d)"
curl -fsSLO https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_arm64.tar.gz &&
curl -fsSLO https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt &&
sha256sum --ignore-missing -c gitleaks_8.30.1_checksums.txt &&
tar xzf gitleaks_8.30.1_linux_arm64.tar.gz gitleaks &&
sudo install -m 0755 gitleaks /usr/local/bin/
cd -Expected: gitleaks_8.30.1_linux_arm64.tar.gz: OK, and only then the install. cd - takes you back to where you started.
On the Spark, if Ubuntu’s copy is installed too (dpkg -s gitleaks finds it), remove it, so there is only one gitleaks on the box and nothing depends on PATH order:
sudo apt remove gitleaksNo package manager sees a direct install, so gitleaks never updates by itself. The upgrade steps are in Updates.
Create your denylist
~/.config/local-ai/denylist is private. You create it by hand, on each machine — it is never generated, and never committed.
On the Mac and the Spark, each:
mkdir -p ~/.config/local-ai && touch ~/.config/local-ai/denylistEdit it with your own editor: one case-insensitive regex per line, for every term that must never appear in this public repo — the tailnet’s name, the NAS’s names, the LAN subnet prefix, anything else private. Blank lines and lines starting with # are ignored. Add at least one term before the next step: a denylist with no terms would pass every commit, so the hooks and make hooks refuse it (denylist has no terms). They refuse a line that isn’t a valid regular expression too, naming its line number but never showing it.
Turn the hooks on
On the Mac and the Spark, each:
make hooksThis makes the hooks’ own checks first: gitleaks has the git command (8.19 or later), and your denylist exists, has terms and parses. Then it points this clone’s git hooks at .githooks.
The hooks also run uv (on the Spark it lives in ~/.local/bin), so both gitleaks and uv must be on the PATH of whatever runs git commit. A GUI client, or an editor over Remote-SSH, may not load your shell profile; a hook refuses its commits with a message naming what’s missing.
Prove they bite
On the Mac and the Spark, each:
make hooks
printf 'box at %s\n' "$(printf '%s.%s.%s.%s' 192 168 50 7)" > leak-drill.md
git add leak-drill.md
git commit -m "test: leak drill" ; echo "exit=$?"
git restore --staged leak-drill.md && rm leak-drill.mdExpected: the commit is refused (private IPv4 address), exit=1, nothing committed.
What they check
Every commit’s message, and every staged file except a deletion, runs through gitleaks and then the repo’s own patterns plus your denylist:
- A file’s name as well as its contents. A name counts as one more line of text.
- Type changes. A symlink turned into a file is read as the file; a file turned into a symlink is read as its target path, which is what git stores.
- UTF-16 and UTF-32 text that starts with a byte-order mark is read as text.
- Binaries can’t be read. A file with a NUL byte and no byte-order mark, such as a screenshot, is named on the way through —
leakcheck: not scanned (binary): <path> — check it by eye— and the commit goes ahead. Look at it yourself before you push.
A finding prints its location and a 3-character excerpt. A path in that output has each match cut to 3 characters too, so a private term in a file name never prints whole.
Before every push
The hooks check each commit as it is made, against the denylist as it was then, and CI has no denylist at all. So a push can carry things no denylist has checked:
- commits made in a clone before
make hooksturned the hooks on; - a merge git completes by itself: git runs
pre-merge-commitfor it, which.githooksdoesn’t define, instead of pre-commit; - a rebase, even one that stopped for a conflict, and a cherry-pick that applied cleanly: neither runs the commit hooks;
- a term you added to the denylist after the commit that contains it.
A conflicted merge or cherry-pick is different: finishing it with git commit or --continue runs both hooks (checked with git 2.54 on the Mac and Ubuntu 24.04’s 2.43).
On the Mac or the Spark, in the clone you push from: scan everything about to go out, patches and commit messages, with your denylist; then the tracked files; then gitleaks. Put the branch’s name in place of <branch>, or main for a branch that isn’t on GitHub yet. git fetch first, so origin/<branch> is what GitHub has:
git fetch
f=$(mktemp)
git log -p --cc --format='%H%n%B' origin/<branch>..HEAD > "$f" &&
uv run --frozen --project spark spark leakcheck --message "$f"; echo "outgoing: exit=$?"
rm -f "$f"
uv run --frozen --project spark spark leakcheck --tracked; echo "tracked: exit=$?"
gitleaks git --log-opts=origin/<branch>..HEAD --redact --no-banner --config .githooks/gitleaks.toml .Expected: outgoing: exit=0, tracked: exit=0, and gitleaks’ no leaks found. The block uses mktemp, which works on macOS and Ubuntu alike; $TMPDIR is set on macOS but usually not on Ubuntu.
A finding prints its location and a 3-character excerpt, and exits 1. Then don’t push: nothing is public yet. A finding in the tree needs a new commit that removes it; one in an unpushed commit’s patch or message needs that commit rewritten, and a Claude session asks you before it rewrites anything. Then scan again.
It is the only check that reads everything going public with your denylist. Missing it was the only Critical finding in Phase 0’s reviews.
When CI’s leaks job is red
CI runs gitleaks and the repo’s patterns, without a denylist (it has none), on every push and pull request. Read the red step’s log first. Only two kinds of line are findings: gitleaks’ Finding: blocks, which end in leaks found, and the leak check’s leakcheck: <where>:<line>: <kind> (…) lines. A red step without them failed for another reason:
- download gitleaks — the network. Re-run the job.
- check gitleaks against its pinned checksum, then unpack it — the download doesn’t match the pin: tampered or corrupt. Don’t use it. Re-run once; if it fails again, find out why before touching the pin.
- a repo patterns step with no finding lines —
uv run --frozencouldn’t fetch the project’s packages, or the leak check stopped on a setup error (exit 2). Its log says which. Re-run the job, or fix what the log names.
Finding lines, in gitleaks over the whole history or either repo patterns step, are a real finding, and it is already public. Follow CLAUDE.md’s procedure for something sensitive that got pushed: rotate the credential first, then rewrite history.
The history step reports a finding as commit message:<line>: a line of git log -p --cc --format='%H%n%B'’s output at the commit CI checked, <sha> (the run’s page shows it). To find the commit without printing the line itself, regenerate that output on the Mac at <sha>, and print the hash of the commit the line falls in:
git log -p --cc --format='%H%n%B' <sha> > "$TMPDIR/history.txt"
awk -v n=<line> 'NR <= n && length($0) == 40 && /^[0-9a-f]+$/ {c = $0} NR == n {print c; exit}' "$TMPDIR/history.txt"A pull request’s run, a Dependabot PR’s included, checks out GitHub’s merge commit, which isn’t in your clone. On the Mac, before that block, git fetch origin pull/<number>/merge fetches the PR’s current one; use FETCH_HEAD as <sha>. It matches the run’s only while neither branch has moved since.
Merging Dependabot’s PRs. The allow marker can’t excuse a finding in history: it would only change a new commit, never the old one. So read a Dependabot PR’s commit message before you merge it, and merge it on github.com with a merge commit or a rebase, not a squash that pastes its release notes into the message (Updates).
When a line is safe but flagged
Add leakcheck: allow to that line, as a trailing comment — the scanner then skips its built-in patterns (addresses, MACs, names) on that line. It never excuses a denylisted term: the denylist checks every line. Use it sparingly: it is visible in review, so it should only ever mark something a reviewer can see for themselves is safe.
Never bypass this
Never git commit --no-verify in this repo.