claude-spill-guard

module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 26, 2026 License: MIT

README

spill-guard

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

tests Claude Code plugin

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.

Directories

Path Synopsis
cmd
spill-guard command
Command spill-guard scans local content for secrets and PII before a Claude Code session can send it to the API.
Command spill-guard scans local content for secrets and PII before a Claude Code session can send it to the API.
internal
bash
Package bash splits a Bash command string into the simple commands it runs, so a caller can find the file operands the readers -- `cat`, `head`, `tail`, `grep` -- are pointed at and scan those the way the Read tool's payload is scanned.
Package bash splits a Bash command string into the simple commands it runs, so a caller can find the file operands the readers -- `cat`, `head`, `tail`, `grep` -- are pointed at and scan those the way the Read tool's payload is scanned.
hook
Package hook is what Claude Code invokes.
Package hook is what Claude Code invokes.
readers
Package readers decides which tokens of one simple command name files the command READS.
Package readers decides which tokens of one simple command name files the command READS.
rules
Package rules loads the ruleset.
Package rules loads the ruleset.
scan
Package scan runs the pipeline over one buffer: skip binaries, gate the credential rules on a literal prefilter, match one rule at a time, and hand every candidate to the checks in internal/validate.
Package scan runs the pipeline over one buffer: skip binaries, gate the credential rules on a literal prefilter, match one rule at a time, and hand every candidate to the checks in internal/validate.
selftest
Package selftest answers the question reading a configuration cannot: is this binary scanning, or is it installed and inert?
Package selftest answers the question reading a configuration cannot: is this binary scanning, or is it installed and inert?
testvec
Package testvec loads the credential-shaped strings the tests assert on.
Package testvec loads the credential-shaped strings the tests assert on.
validate
Package validate holds the checks that decide whether a regex match is a finding.
Package validate holds the checks that decide whether a regex match is a finding.
Package rules carries the shipped ruleset as bytes compiled into the binary, which is how docs/design/distribution.md settles version skew: a binary and a ruleset that cannot be separated cannot disagree.
Package rules carries the shipped ruleset as bytes compiled into the binary, which is how docs/design/distribution.md settles version skew: a binary and a ruleset that cannot be separated cannot disagree.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL