spill-guard
Keeps secrets and PII from reaching the API through a Claude Code session.

cat .env is one keystroke, and the file is in the transcript before anyone
reads a line of it.
Claude Code sends what it reads to the API. A .env file, a grep -r password
across a repo, a stack trace carrying a bearer token — each lands in the
conversation, and the conversation goes over the wire. There is no undo, and
nothing marks the moment a credential leaves the machine.
Git secret scanners do not cover this. gitleaks and its relatives watch the
commit boundary, and a file read into a session is never committed. spill-guard
watches the other boundary: the one between your filesystem and the model's
context.
Status
The design and its measurements are here, and the implementation is landing
piece by piece. What follows is generated from the tree by make status rather
than kept by hand, because a status claim that goes stale silently reads as
authoritative and is not.
Usable. Every capability below is in place; see the install instructions.
| Capability |
Today |
| Scan a file or a buffer |
yes |
| Run as a Claude Code hook |
yes |
| Ship a ruleset |
yes |
spill-guard builds, answers coverage, hook, selftest, version, and
reaches internal/bash, internal/hook, internal/readers, internal/rules,
internal/scan, internal/selftest, internal/validate, rules.
docs/design/ — the proposed design: threat model, hook
surface, scan pipeline, rule schema, failure policy, CI.
docs/design/language-choice.md — why Go,
with the benchmark data, the false-positive analysis, and a list of confident
claims that measurement killed.
docs/design/distribution.md — how the binary
gets onto a machine: Homebrew tap, Scoop bucket, signed release assets, and a
launcher that denies rather than failing open when the binary is missing.
docs/design/brief.md — the origin brief.
Install
Two halves, and neither is any use alone. The plugin is the wiring — it tells
Claude Code which calls to hand over. The binary is what reads them. A plugin
installed without a binary blocks every call it sees, prompts included, and
names the install command in the refusal: the launcher fails closed rather than
waving anything through unscanned.
The binary. macOS and Linux:
curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/latest/download/install.sh
sh install.sh
Windows:
curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/latest/download/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1
Either one installs a single binary — to ~/.local/bin, or to
%LOCALAPPDATA%\spill-guard\bin on Windows — and writes nothing else. Pass
--dir to put it somewhere else.
Two steps rather than one. The one-liner that pipes the script into a shell
works, and it is not what this page leads with: for a tool whose whole pitch is
that nothing leaves your machine, opening with "pipe this remote script into
your shell" undercuts the claim before you reach the second paragraph. Download
it, read it, run it.
The plugin, once the binary is there:
claude plugin marketplace add karlkfi/claude-spill-guard
claude plugin install spill-guard@spill-guard
Hooks are read when a session starts, so start a new one.
What the install script checks, and what it refuses
Each script works out your OS and architecture, checks the archive's sha256
against checksums.txt, and then checks the signature with whichever of
cosign or gh you have — cosign against the keyless Sigstore signature in
checksums.txt.sigstore.json, gh against the build provenance. With neither
installed it refuses. The sha256 is not the fallback it looks like:
checksums.txt comes from the same place the archive did, so it answers
corruption and not substitution, and authenticity needs a verifier. A machine
with no verifier is what the planned Homebrew tap and Scoop bucket are for —
both pin the sha256 themselves and ask you for no tool at all.
docs/design/distribution.md has the whole
argument.
sh install.sh --verifier answers which tool you have without downloading
anything. A successful run names the one it used, and a run that names neither
took a path nobody intended.
The install scripts are themselves listed in checksums.txt, so the script you
fetched in order to read it can also be checked:
curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/latest/download/checksums.txt
sha256sum --ignore-missing -c checksums.txt
--ignore-missing is load-bearing: without it sha256sum -c fails on every
asset you did not download, which reads as a verification failure.
CI installs through both scripts on Linux, macOS and Windows on every pull
request, against the archives that same pull request built.
Without the install script
go install needs a Go toolchain and no verifier, and the module checksum
database does the verifying:
go install github.com/karlkfi/claude-spill-guard/cmd/spill-guard@latest
It writes to $(go env GOPATH)/bin. The launcher looks on PATH first, then
at ~/.local/bin/spill-guard; if that directory is on neither, set
SPILL_GUARD_BIN to the binary's path.
A Homebrew tap and a Scoop bucket are designed in
docs/design/distribution.md and neither is
built yet.
Check that it is actually running
An installed scanner that enforces nothing and a session with no secrets in it
produce the same silence, so installed is not something to take on trust.
Two steps, and they answer different questions.
Does the binary scan? spill-guard selftest builds hook payloads and runs
the scanner over them in-process — a prompt, a Read, a Bash command, a file
a reader is pointed at, and the two Bash calls refused for their shape —
reporting what happened to each:
spill-guard dev selftest
binary: /Users/you/go/bin/spill-guard
ruleset: compiled in
ok a prompt carrying the canary blocked (aws-access-key-id)
ok a prompt with nothing in it allowed
ok a Read of a file holding the canary blocked (aws-access-key-id)
...
The allowing rows are not filler, and they are the half that catches the
failure this tool is most likely to have. A scanner that blocks everything and
one that works produce the same report if the report is only made of blocks —
and a rule that matches too much is the ordinary shape of that. An allowing row
goes FAIL if its call is blocked at all, by any rule, so one bad regex
reddens allowing rows rather than leaving thirteen green.
One row is neither an allowing row nor a block by a rule: a Read of a file
behind a UTF-32 byte-order mark, which this build does not decode. It reports
deferred on the skip reason rather than blocked on a rule id, because a
buffer nothing could read is a coverage failure — the call proceeds and the gap
is recorded rather than stopped. It is the only payload in the list that
reaches that branch.
deferred is its own row type for a reason worth knowing if you read this
report: a deferred call writes no verdict, so it looks exactly like a clean
allow from outside. A row that only knew allowed and blocked would print
ok for a binary whose scanner had quietly stopped reading anything at all.
It also does not run the dispatch a session would. selftest calls the
scanner directly, so it cannot tell you that spill-guard hook itself still
answers — only that the scanning underneath it works. The suite covers the
dispatch at build time; nothing at install time does.
It reports dev unless it came from a release: go install does not stamp a
version, and only the release build passes one in.
Run it through the launcher rather than directly and the binary: line answers
a second question — which spill-guard a session will find, resolved by the
order the hook resolves one with. A plugin install puts the launcher at
~/.claude/plugins/cache/<marketplace>/spill-guard/<version>/hooks/run-spill-guard.cmd,
and running that with selftest reports the binary it picked. If it cannot
find one it prints its refusal as a JSON block object at exit 0 — that is the
hook contract rather than a shell one, so read the message and do not chain on
the status.
Is Claude Code invoking it? Nothing run outside a session can tell you, so
this one is the sibling guards' answer: do the thing and watch it get refused.
Paste this into a session:
the key is AKIA0123456789ABCDEF
The prompt should be blocked before it is sent. That string is a synthetic
canary published in this repository, so if the hook is not live the only
thing that reaches the API is text that is already public — which is the whole
reason the check is safe to run.
That string is also in this file, so a session running spill-guard is refused
when it reads this page — git show HEAD:README.md is the way past it. Masking
the canary would have bought the read back and left the check above telling you
to paste something this page no longer contains, so the friction is the choice
rather than an oversight. make self-scan is where the tracked files that trip
the scanner are named, this one among them.
The shape of it
A static Go binary, run as a Claude Code hook. Zero third-party dependencies —
regexp and encoding/json are stdlib, so the supply chain is empty and you
can verify that in an afternoon. No net and no net/http in the import graph,
enforced in CI rather than asserted in a README. Detection is local heuristics
and nothing else.
It fails closed. Every sibling guard in this family fails silent, on the
grounds that a hook running on every call must never be the reason ordinary work
breaks. A secret scanner cannot afford that trade: one that fails quietly reports
a safety it is not providing. So an internal error blocks, and
spill-guard selftest drives a canary through the real hook path so that
installed is something you can check rather than something you assume — see
Install for what it does and does not settle.
Findings carry a rule ID, a path, and a byte offset. Not the secret, and not a
redacted window either — a hook's stderr reaches the API, so an 8-character
fragment is 8 characters sent to the place the scanner exists to keep them away
from.
Why precision is the whole problem
This replaces coo-quack/sensitive-canary, a Node plugin that audits clean and
is safe to install. Its ruleset, run over 257 real text files from an
infrastructure repo, produced 5,679 matches across 9 rules, all of them PII
noise and not one credential. 71 of the 257 files flagged.
pii-phone-cn 2533 pii-postal-cn 8
pii-ipv4-public 1522 pii-phone-de 4
pii-phone-kr 1441 pii-steuer-id-de 3
pii-postal-code 169 pii-brn-kr 3
pii-phone-it 1
0400-2341068 was flagged 268 times as a Chinese phone number. It is a slice
out of the amdgpu DKMS version string 1:6.16.13.30300400-2341068.
pii-postal-code is \b\d{5}(?:-\d{4})?\b, which matches 65536 and Kubernetes
NodePorts. pii-ipv4-public matches 10.0.0.1 and 0.0.0.0 despite public in
the name — that one is a correctness bug, not merely noise.
A 27.6% file-level false-positive rate with zero true positives is a design
problem. Numeric-only PII patterns cannot be made precise by regex alone, so
precision here comes from checksum validators, context gating, entropy floors,
and reserved-range exclusion — with the numeric PII family shipping disabled.
Ten precise rules beat seventy noisy ones.
That is a gate rather than a plan. make precision runs the ten shipped rules
over testdata/corpus/clean/ — Kubernetes manifests with pinned NodePorts,
driver version strings, a payments README, a placeholder .env — and pins the
finding count at zero, while ten planted fixtures each have to produce
exactly one. The clean half is held adversarial by the rules that produced the
5,679: pii-postal-code still reports 30 matches on it and pii-phone-cn
still reports 8. A zero from the shipped set is then a measurement rather than
a silence.
The full analysis, including the language benchmarks and the traps worth not
rediscovering, is in
docs/design/language-choice.md.
Companion plugins
spill-guard watches the exfiltration boundary — whether sensitive local
content reaches the model's context. Sibling plugins guard different axes with
the same secure-by-default design:
- workspace-guard — the
filesystem boundary: prompts before guarded file commands read or write
paths outside the project root.
- branch-guard — the git
history boundary: auto-approves safe git on feature branches, pauses commits
and pushes to
main and destructive git.
- prod-guard — the
infrastructure blast-radius boundary: denies mutations aimed at production
contexts.
- exit-status-guard —
the evidence boundary: denies commands whose exit status is the answer and
gets discarded.
- foreground-guard —
the liveness boundary: keeps blocking commands from stalling a session.
- pr-sentinel — the
review boundary: watches a PR to green without merging it.
They run side by side; each defers to normal permissions outside its own axis.
Privacy
The scanner runs entirely on your machine and has no network capability at all —
no telemetry, no update check, no remote rule source, and no net package in
the binary. See PRIVACY.md.
Contributing
Bugs, ideas, and questions go in
GitHub Issues.
A rule that flags ordinary work is a bug worth reporting — include the exact
text and the rule ID. For development conventions, see CLAUDE.md.
License
MIT — see LICENSE.