Skip to content

Releases: karlkfi/claude-spill-guard

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 20:16
01f6204

spill-guard keeps secrets and PII from reaching the API through a Claude Code session. It scans at the boundary between your filesystem and the model's context — the one a git secret scanner never sees, because a file read into a session is never committed.

Warning

Upgrade if you are on v0.4.1. Three Bash spellings read a credential file with no verdict and no coverage record: an append prefix (LC_ALL+=C cat <key>), a subscripted prefix (FOO[0]=x cat <key>), and, where Claude Code's tool shell is zsh, a ** glob that zsh recurses and bash does not. A private key added or removed in a unified diff was not reported either. All four are closed here.

Note

This is a 0.x release and the surface carries no compatibility promise. Two environment reads are new, CLAUDE_CODE_SHELL and SHELL, and the path one of them holds is now written into a coverage record. The surface this freezes has the detail.

What v0.5.0 is for

Closing fail-opens, and reading the shell Claude Code actually runs. No new subcommand, no new flag, no new configuration.

Every expansion here was bash's, and Claude Code runs zsh unless CLAUDE_CODE_SHELL or SHELL names bash. The two shells expand most patterns identically and a few very differently, so this release reads which shell the Bash tool will use and stops guessing where they disagree.

The tag was held once, on 2026-09-26. The first fix for zsh refused every glob outside bash, so on a zsh machine cat *.env over a key went from a block on v0.4.1 to no verdict. That shipped nowhere: the narrower fix below replaced it before the tag.

Highlights

  • Two command prefixes no longer hide the reader behind them. Bash peels NAME+=value and NAME[sub]=value off the front of a command and runs what follows, so LC_ALL+=C cat <key> and FOO[0]=x cat <key> both print the file. Here each prefix was read as the command name, which has no reader row, so the file was never opened and nothing was recorded. Both now block, naming the rule. A subscripted SPILL_GUARD_OVERRIDE[0]= does not arm the escape hatch, because as a prefix bash sets nothing from it.
  • On a zsh tool shell, a glob is expanded where zsh agrees with bash and refused where it does not. 37 patterns were driven through both shells: 30 give the same files, and the other 7 either read nothing in either shell or read a superset here. What zsh reads differently defers with a coverage record: a ** operand, which zsh recurses; a ( glued to a word, such as *(D), which zsh reads as a glob qualifier that can add files; and an option change anywhere in the command, such as setopt globdots or set -o globdots. Any other shell, and a machine where nothing names a shell, refuses every glob.
  • private-key-block steps over a unified diff's + and -. A key added or removed in a commit reaches a session through git diff, git show and a .patch file, and in either direction it crossed unreported. A plain key on added lines was matched by accident, because + is in the base64 alphabet. An encrypted key on added lines, and any key on removed lines, was not.
  • A command bash cannot run no longer blocks. n=LC_ALL; $n=C cat <key> blocked over a file bash never opens: bash decides what a word is before it expands anything, so it looks for a program literally called LC_ALL=C and reads nothing. This is a precision fix. Nothing crossed that should not have.
  • A scan whose budget is already spent always defers. Before, the deadline raced the scan to the verdict, and once the race was entered the scan won it 50.2% of the time, as measured when the defect was filed, so two identical calls at an exhausted budget could get different answers.

Driven on built binaries

v0.4.1 built from its tag, against main at 099eeaa, which carries the code this tag ships. SHELL set as shown, CLAUDE_CODE_SHELL unset. "Shell reads the key" is the shell running the command itself.

SHELL command shell reads the key v0.4.1 v0.5.0
bash cat aws-access-key-id.env (control) yes deny deny
bash LC_ALL+=C cat aws-access-key-id.env yes no verdict, no record deny
bash FOO[0]=x cat aws-access-key-id.env yes no verdict, no record deny
bash n=LC_ALL; $n=C cat aws-access-key-id.env no deny no verdict
bash cat private-key-block-in-diff-added.patch — no verdict deny
bash cat private-key-block-in-diff-removed.patch — no verdict deny
zsh cat **/*.env, key two directories down yes no verdict, no record defers, recorded
bash cat **/*.env, same tree (control) no no verdict no verdict
zsh cat *.env yes deny deny
zsh cat *(D), key in .env yes no verdict, no record defers, recorded
zsh setopt globdots; cat *, key in .env yes no verdict, no record defers, recorded
zsh cat *, key in .env (control) no no verdict no verdict

The two diff fixtures live in testdata/corpus/planted/, taken from git diff output on git 2.55.0. A deferred call and a clean allow both exit 0 with no verdict, so the "recorded" column is read off the coverage log rather than off the exit status.

Upgrading

Both halves move. The binary is what changed; the plugin manifest version is what makes claude plugin update deliver it.

curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.5.0/install.sh
sh install.sh
claude plugin update spill-guard

Windows, in PowerShell:

curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.5.0/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1

Hooks are read when a session starts, so start a new one. Then run spill-guard selftest and read what it says it could not establish.

No action is required and no configuration changes.

Which shell you have decides what changes. Claude Code takes CLAUDE_CODE_SHELL, then SHELL, each only where the path names bash or zsh and is executable, and otherwise tries zsh first. If you want the tool shell to be bash, set CLAUDE_CODE_SHELL to your bash's path in settings.json.

  • Under bash, glob handling is unchanged.
  • Under zsh, spill-guard coverage gains records for the shapes above, where v0.4.1 wrote nothing. Two of them cost a scan bash would get: a glob after set with any flag, such as set -euo pipefail; cat *.txt, and a glob after a command whose name is still a variable, such as "$X" run; cat *.env. Both defer, and the file crosses as it would with no hook.
  • Under any other shell, or none, every glob operand defers.

What you may stop seeing: a block on n=LC_ALL; $n=C cat <file> and its spellings, which bash never runs.

Deprecations

None. No subcommand, rule ID, environment read or output format was removed.

What it does not read

Each item is a case where a credential can cross. The v0.4.0 list stands, with the changes below. docs/releases/v0.4.0.md carries it in full.

Disclosed before v0.5.0
An append assignment as a command prefix (v0.4.0) Closed.
A zsh tool shell, filed and not driven (v0.4.0) Globbing driven and narrowed, as above. Everything else this hook resolves is still read as bash, and the three shapes below are what that has cost so far.

New in this list, every one of them present on v0.4.1 too. Each was driven, and each exits 0 with no verdict and no coverage record while the shell reads the key:

  • A wrapper in front of a reader. command cat <key>, command -p cat, exec cat, nice cat and nohup cat. The wrapper has no reader row, so the command behind it is never found. time cat <key> blocks, because time is a reserved word and is peeled.
  • A substituted command. V="cat <key>"; $V. Bash word-splits the value and runs cat; here a substituted value stays one token.
  • A subscript the lexer splits. FOO[$((1+1))]=x cat <key>, FOO[a b]=x cat <key> and two spellings with a quoted or escaped ]. The ordinary subscripts above are closed. These four are held by a test that fails when they close.
  • Three zsh patterns the segmenter splits as bash operators. cat (k1|zz).env, cat k<1-1>.env and cat a.txt(P:k1.env:). They never reach the glob check at all.
  • A standalone CDPATH assignment. CDPATH=/elsewhere; cd sub && cat key.env is scanned against ./sub/key.env, while bash reads /elsewhere/sub/key.env when both exist. Unlike the others this one gets a verdict, but it is a verdict on the wrong file. The inline form, CDPATH=/elsewhere cd sub, defers correctly.
  • Two diff shapes private-key-block does not cross. A hunk header (@@ -20,3 +20,4 @@) between a key's header and its body, and a combined diff's second marker column, such as ++ or - on an encrypted key. The first is what git diff writes when a change sits within three lines of a key.

There is still no Homebrew tap and no Scoop bucket. Both are designed and neither is built.

The surface this freezes

Small on purpose, and 0.x means none of it is promised.

  • Subcommands: hook, selftest, version, coverage. Unchanged. None takes a flag.
  • Rule IDs, on by default: aws-access-key-id, github-token, github-fine-grained-pat, slack-token, slack-webhook-url, stripe-live-secret-key, openai-api-key, google-api-key, private-key-block, jwt. One changed what it matches, private-key-block, and its ID did not change.
  • Rule IDs, off by default: payment-card, us-ssn, cn-resident-id, ipv4-public.
  • Environment: two new reads. The binary now reads CLAUDE_CODE_SHELL and SHELL, beside HOME, TMPDIR, XDG_STATE_HOME, CDPATH, GIT_DIR, GIT_WORK_TREE and GIT_CEILING_DIRECTORIES. Neither shell is spawned, since nothing here runs a shell. `SPILL_GUARD_OVERRIDE=...
Read more

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 10 Sep 14:31
03594fe

Upgrade if you are on v0.4.0. A quoted word made only of shell operator characters — cat ';' f — cut the command in two before the scanner read it, so every operand after that word went unscanned. No verdict, no coverage record, exit 0. The file it read had an ordinary name.

One fix, in the shell segmentation, and it closes two things.

A file the command reads, that nothing scanned. A word spelled entirely from the characters shlex groups as punctuation — ;()<>|& and a newline — has the text of an operator and is not one when quotes made it a word. Reading it as a separator ended the segment there: cat kept no operands, and f was read as a command name with no reader row, so neither file was opened. No file named ; has to exist for this to bite. The split happens at tokenization and cat continues past the operand it could not open, so the credential file is ordinarily named and what is unusual is one quoted operator-only word anywhere in the command.

The escape hatch was armable from a position that is not command position. cat f ';' SPILL_GUARD_OVERRIDE=x downgraded a block to a confirmation, because the quoted ; opened a fresh segment and a trailing override fell into env-prefix position. The design rules the hatch to command position by name, so this is the narrower half in effect and the sharper half in kind.

Measured on built binaries either side, in a directory holding only f with a planted AWS key:

command v0.4.0 v0.4.1
cat ';' f, no file named ; no verdict object, exit 0 deny, naming aws-access-key-id
cat f ';' SPILL_GUARD_OVERRIDE=x ask deny
cat f — control deny deny
cat 'x;y' f — control deny deny
cat f SPILL_GUARD_OVERRIDE=x — control deny deny
SPILL_GUARD_OVERRIDE=x cat f — control ask ask

The controls are the point rather than decoration: two of them move in opposite directions across the hatch fix, and 'x;y' says the mechanism is the split rather than the quoting.

Two sites, and the obvious one alone does not fix the headline case. A single-character run is byte-identical whether it is decomposed or skipped, so a quoted ; reaches the segmenter's own vocabulary tests intact; patching only the operator-run splitter fixes cat ';;' f and leaves cat ';' f splitting in two. Ported from claude-bouncer #138, which converts three sites in workspace-guard; there are two here, because this reads the hatch off a segment rather than re-scanning raw tokens.

The precision risk, and who measured it

This change makes the segmenter treat more tokens as words, so what it risks is something that should segment no longer doing so — and the author of a change is the worst person to choose its controls. An independent session measured it over 155,000 unique Bash command strings from real transcripts, diffing the file set the hook resolves between binaries built either side: 154,969 file sets identical, 27 gain files, 4 lose, 0 both. All four losses are non-file tokens — --, -e, $b, \n, and a git show <rev>:<path> pseudo-path — so no real path stops being scanned. Every gain is a real path: grep -c ';' CLAUDE.md now scans CLAUDE.md, and find … -exec … \; keeps its -exec clause.

No gate re-runs that measurement, and it is the only evidence for the precision direction of this change. Said here rather than implied.

The surface this freezes

Unchanged from v0.4.0. No subcommand, flag, rule ID, environment read, output format or reason string moved.

What it does not read

The v0.4.0 list stands, minus the entry this closes. docs/releases/v0.4.0.md carries it in full, and the one item that changed is the quoted-operator case above.

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 10:46
ea21b72

spill-guard keeps secrets and PII from reaching the API through a Claude Code session. It scans at the boundary between your filesystem and the model's context — the one a git secret scanner never sees, because a file read into a session is never committed.

Warning

This is a 0.x release and the surface carries no compatibility promise. Subcommand names, rule IDs, the reason strings, and the shape of a verdict can change in the next minor.

Caution

One rule matches less than it did. aws-access-key-id drops the A3T prefix arm, so a key beginning A3T is no longer matched. Deprecations has what the cut is and is not sourced to. Nothing else in this release narrows what is caught.

Note

The scanner is 2.2x faster on the shipped ruleset — 35.14–35.51 MB/s to 76.61–76.76 MB/s over this repository's own text — and jwt gained no length limit to pay for it. Highlights has the measurement and the control arm that makes it attributable.

What v0.4.0 is for

Closing edges the v0.3.0 notes published, and the one performance cliff that was left. No new feature, no new subcommand, no new configuration.

The v0.3.0 notes ended with a list, under What it does not read, of every case where a credential can cross. That list is published, so it is the commitment a reader can hold this project to without a checkout, and working it down is what this release is. Three of its members are answered here — two closed, one driven from undisclosed guess to measured fact.

The second half is jwt, which was the eighth of eight rules opening on \b and the only one the loader still refused the anchored path. eyJ heads any base64-encoded JSON object, so it is a keyword ordinary content carries — lock files, JWKS documents, CI configuration — and each of those bought a whole-buffer scan.

Underneath both is release-path work no user sees: a signing verification that now has to prove it can reject, a provenance check that reads an identity rather than an exit status, and three new gates. That is named in Validation rather than sold as a feature.

Highlights

  • jwt runs at its keyword positions, and the whole ruleset is 2.2x faster. It was the eighth of eight rules opening on \b and the only one the loader refused the anchored path, because an unbounded repeat over a class covering the text reads to the end of the buffer once per keyword hit. It is now two pieces rather than one pattern: \b(eyJ[A-Za-z0-9_-]{8}) establishes that a token starts here, and an extent walks forward to find where it ends, widening the capture before any check reads it. There is no length limit on a JWT segment — the walk terminates on any byte outside base64url, and end-of-buffer is that same terminator rather than a missing one, so it cannot run off the end.
  • The MCP file reader is driven, and it is the last member of the reader class to be. It was disclosed as undriven in every release since v0.1.0. It is a real member — the hook sees the call as mcp__<server>__<tool> and a deny stops it before the server is asked, so it is stoppable. It stays out anyway, and the reason is sharper than expected: one server's two tools split, read_file bounded on a path and search_tree unbounded on a root, with nothing in the payload saying which arrived. The split is inside one server, so membership cannot be decided per server or per protocol — a per-server rule would owe those two tools opposite answers. Five claude -p runs on 2.1.261, every verdict read from the session transcript rather than from printed output.
  • The glob expansion's assumption is now a measurement. v0.3.0 disclosed that the expansion follows bash's defaults on the strength of 22 of 22 shell snapshots on one machine. It is 62 of 62 now, 3,720 shopt lines, with a positive control — 15 options come back -s in every snapshot, so the parse can return non-empty. Two things fell out. Claude Code narrows one option itself: every Bash tool call runs shopt -u extglob, so that option cannot be on for a tool shell whatever your rc file says, leaving five in the residual rather than six. And the hook cannot find the snapshot its own session sources — no shell state in the payload, none of 55 environment variables naming one, no session id in the filename, and newest-by-mtime is wrong (the driving session's was 4th of 63). So nothing is buildable here, and the gap is bounded rather than closed.
  • aws-access-key-id cuts the A3T prefix arm. AWS's IAM identifiers reference names twelve prefixes and A3T is not among them; the ruleset carried it because it was inherited rather than written. ABIA and ACCA are on that list and stay, now with the citation beside them. Four prefixes ship: AKIA, ASIA, ABIA, ACCA.
  • private-key-block steps over a whitespace-only separator line. The rule crossed from header to base64 body across [\r\n]+, so a separator carrying a space or tab was not crossed and the key went unreported. The producer is git diff: unified format prefixes every context line with a space, so a blank line inside a quoted file arrives carrying exactly one — driven on git 2.55.0 rather than argued from the format spec. Both steps widened, not the header's alone: in an encrypted key the whitespace-only line falls after DEK-Info:, and a header-only fix reports three of the five new cases and misses that one and the planted patch.
  • The install scripts pin the tag when they verify with gh. See Security — this one is a weakness in this project's own verification, and it is disclosed at length there rather than summarised here.

The jwt measurement

BenchmarkRuleset over this repository's own text, 1.73 MB in 196 files, -benchtime 30x -count 3.

v0.3.0 v0.4.0 as shipped
the shipped ruleset 35.14–35.51 MB/s 76.61–76.76 MB/s
control: every anchor cleared 7.24–7.39 MB/s 7.37–7.40 MB/s

The control row is what makes the top row readable, and it is not decoration. It is the same benchmark with every rule forced onto the whole-buffer path, so it measures the machine rather than the change. The two runs are hours apart and it agrees to within 2%, which is what licenses comparing their first rows at all — a benchmark whose control has moved underneath it says nothing about the change, however clean the headline number looks.

Upgrading

Both halves move. The binary is what changed; the plugin manifest version is what makes claude plugin update deliver it.

curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.4.0/install.sh
sh install.sh
claude plugin update spill-guard

Windows, in PowerShell:

curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.4.0/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1

Two steps rather than one on purpose. The one-liner that pipes a remote script into a shell works, and for a tool whose whole pitch is that nothing leaves your machine, opening with "pipe this into your shell" undercuts the claim before you reach the second paragraph. Download it, read it, run it.

Hooks are read when a session starts, so start a new one. Then run spill-guard selftest and read what it says it could not establish.

No action is required and no configuration changes. No new subcommand, no new flag, no new environment read, no change to the coverage log's format or location, and no reason string was rewritten.

What you will stop seeing. Scans finish faster — 2.2x on the shipped set over ordinary text — and the effect is largest on content carrying eyJ, which is lock files, JWKS documents and CI configuration rather than anything unusual.

What you may stop catching. Nothing, except a key beginning A3T.

If you read spill-guard coverage regularly, nothing changes in this release: no coverage reason was added or removed. The for-loop and case-arm reasons stopped growing in v0.4.0's first half and remain closed.

Deprecations

  • The A3T arm of aws-access-key-id is removed, with its keyword, its test-table row and its vector. A key beginning A3T is no longer matched by this rule. The cut is not sourced to a zero: A3T reports 0 raw matches over 5.05 GiB of third-party Go source, and so does AKIA on the surviving column — exactly the reading rules/README.md forbids drawing an arm's death from. What settles it is that AWS does not publish the prefix. It was not settled by agreement with another scanner's ruleset either, since docs/design/brief.md names gitleaks as a possible origin of these rules and agreement there would be one list arriving twice.
  • Nothing else is deprecated, and no subcommand, rule ID, environment read, or output format was removed.

What it does not read

Each item is a case where a credential can cross. They are listed because a scanner's gaps are the only part of it a user cannot discover for themselves. v0.3.0 disclosed eleven; this is every one of them, plus what this release adds.

Disclosed in v0.3.0 v0.4.0
A UTF-8 byte-order mark ahead of a NUL Stands. Undeclared to the decoder, so the buffer falls through and the NUL is read raw. The call proceeds and the person is told in a notice.
A binary buffer on Read and on a Bash operand Stands. The raw match pass is the prompt's alone, on the cost argument: 13 of the 21 binary Bash operands in the design corpus are the Claude Code binary, 306 MiB, 36.5 seconds of match loop.
A recursive read Stands. A walk reads a different file set from the one the command would send — over one checkout rg --files reports 211 where a walk finds 11,815. The call proceeds with nothing scanned and a record. Name the files.
A secret synthesized at runtime, other than a bare env Stands. Structural: nothing to open at PreToolUse, and `P...
Read more

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 08:25
77724d2

spill-guard keeps secrets and PII from reaching the API through a Claude Code session. It scans at the boundary between your filesystem and the model's context — the one a git secret scanner never sees, because a file read into a session is never committed.

Warning

This is a 0.x release and the surface carries no compatibility promise. Subcommand names, rule IDs, the reason strings, and the shape of a verdict can change in the next minor.

Caution

A call v0.2.0 blocked because it could not read it now goes through. An operand the scanner cannot resolve before the command runs, a file behind an encoding this build does not decode, a scan past its 45-second budget: none of these blocks any more. The hook writes no verdict, your own permission rules run exactly as they would without it, and the gap is written to a log that spill-guard coverage reads back. A finding still blocks. So does a read of a credential file and a bare env. If you were counting on the old block to stop a cat $FILE you had not looked at, that stop is gone, and Upgrading says where to look instead.

Note

The escape hatch is a command-position prefix and has never been an environment variable: SPILL_GUARD_OVERRIDE=<reason> <command> on a Bash call, read from the inline assignment and never from the environment, and it turns a block into a confirmation rather than an allow. The v0.1.0 and v0.2.0 notes called it an environment variable in their own banners. They were wrong on that word, the code was not, and this note is where it is corrected.

What v0.3.0 is for

Two things. Friction that was measured, and coverage that was measured. Over the week to 2026-09-05, across 100 sessions on one machine, 509 of 540 blocks — 94.3% — were the scanner refusing a call it could not read rather than a call carrying a secret, and after such a block the session got a clean call through within four attempts 99.2% of the time, reading the same tree another way. The block was not withholding the bytes. It charged a turn, and then a person's attention, for a refusal the next call walked around. All 31 content findings in the same window fired inside this repository's own worktrees, on its own corpus fixtures.

So the block on a coverage failure is gone, and what replaces it is a record. A record of a gap is worth something only if the gaps then close, which is the second half: four of the shapes the old block fired on most are now resolved and scanned rather than recorded. Of the 393 refusals measured over 14,868 Bash calls in the transcripts where the hook was live, 361 landed on four operand-resolution arms — a variable expanded at run time (135), a directory (96), a relative operand after a cd (79), a glob (51). Three of those four arms resolve now. The directory arm stays out on its own argument, below.

The list the v0.2.0 notes committed to is answered case by case at the end, under What it does not read.

Highlights

  • A coverage failure defers and is recorded, instead of blocking. The hook writes nothing at all — not an allow, because an allow is an opinion that would suppress your own configured rules, and this has none to spend on a call it could not read. The gap goes to two sinks: a line on stderr, which Claude Code stores in the transcript beside the call and shows to nobody, and a capped JSONL log under $XDG_STATE_HOME/spill-guard/, or ~/.local/state/spill-guard/ when that is unset. spill-guard coverage reads the log back grouped by reason, commonest first, so the shape that costs the most is the first line. What still blocks: a rule match, a bare env, and a read of a credential file. What still exits 2: a payload the binary cannot decode at all, which is a broken install and not a scanner failing to read something. 0 of the 540 verdicts in the measured week were that.
  • A read of a credential file is refused on its path alone. .env, ~/.aws/credentials, .netrc and _netrc, .git-credentials, ~/.docker/config.json, and a kubeconfig. The shipped set is fourteen high-precision shapes and none of them matches an AWS secret access key, a netrc password, a base64 registry auth or a kubeconfig's client-key-data, which is what these files hold — driven, cat .aws/credentials over such a file exited 0 silent on v0.2.0. Reads only, and only through the readers this knows — the per-command table that decides which token of a Bash command is a file: python3 -c "print(open('.env').read())" walks straight past it, and the reason says so rather than reading as path protection. The careful form is not refused and is still scanned: cat .env | cut -d= -f1 has a stage after it. Over 24,381 Bash calls and 521 Read calls in the seven days to 2026-09-04 the class refused 0 real calls, and 11 of 12 planted ones — the twelfth is ~/.aws/config, deliberately out. .claude/settings.json was the first-named member and the measurement removed it: 29 reads in that week, not one a credential, 26 of them a session reading back a line it had just written.
  • The Bash surface resolves four shapes it used to record or refuse. Two of the four — the cd tracker and the variable map — are ports of claude-workspace-guard's resolution, kept structurally identical rather than rewritten; the input redirect and the glob expansion are this repository's own, since upstream never needed the operator and resolves a glob only inside a for list. The variable map and the glob expansion were driven against bash 5.3.15 and the output checked in, rather than reasoned about from the manual.
    • An input redirect's file is scanned. cat < deploy.env exited 0 silent on v0.2.0 where cat deploy.env blocked, and cat < .env walked past the path refusal the same way — one operator defeating both of this tool's answers. Over 28,187 Bash calls in the week to 2026-09-04, 360 segments carry an input redirect, 219 of them on a reader the table knows and 141 on a command with no row, 77 of those python3. Driven over every one of the 1,431 calls in that week carrying a bare <: 43 moved from allow to deferred, and nothing moved to a block.
    • A literal cd target is followed. cd sub && cat x reads x under sub and gets a verdict, per segment in order, so cd a && cat x && cd b && cat y reads each under its own directory. Followed: a literal target, and the quoted "$(git rev-parse --show-toplevel)" and "$(pwd)". Not followed, as upstream: bare cd, cd -, popd, a $-bearing target, and a move inside a subshell or a pipeline stage. Of 137 coverage records on this arm over this machine's transcripts, 6 named a target nothing can follow.
    • A variable the same command assigned a plain literal is substituted. SP=/x; tail -30 "$SP/unit.log" opens /x/unit.log. That was the commonest shape on the busiest arm: of the 137 commands recovered behind the variable arm's 135 refusals, 86 assigned a literal earlier in the same string, most of them a session writing its scratchpad path down once and reading a log back from it. Anything that is not a provable literal poisons the name and leaves the operand where it was, recorded. An and-or list is read the way bash runs it: an assignment reached through && counts while everything before it is certain to have exited 0, which is 220 of the week's 655 conditional assignments sitting directly after cd "$(git rev-parse --show-toplevel)" &&, and one reached through || does not.
    • A glob is expanded to the files bash would send. grep -n pat internal/hook/*.go opens every file it names, under bash's default options, which 22 of 22 shell snapshots on this machine restore: a leading . is matched only by a literal ., and the literal word is included whenever a file by that name exists, because bash passes an unmatched pattern through and app/[id]/page.tsx is a filename. Anything earlier in the string that could change those options — shopt, set -f, a GLOBIGNORE assignment, eval, source — puts the pattern back on the record, and a brace expansion is recorded rather than passed through as a file that does not exist. 135 operands in 95 measured calls get a verdict where they had a record.
  • Rules run at their keyword positions. Eight of the ten enabled rules open on \b, an empty-width op that leaves regexp no literal prefix to skip towards, so v0.2.0 ran the NFA over every byte to arrive back at positions the prefilter already had. Seven of the eight now run one anchored attempt per hit. Over this repository's own text, which names every keyword the set gates on, the shipped ruleset went from 7.12 MB/s to 32.09 MB/s. A differential over 280,406 buffer-and-rule pairs compared 824 findings between the two paths with 0 disagreements. The 500 MiB fixture that hit the 45-second budget on v0.2.0 now denies on its rule at 14.9 to 17.7 seconds.
  • private-key-block reads an indented PEM block. The rule required the base64 body to begin a line, so a key pasted into a Kubernetes secret's block scalar or an indented Markdown fence matched nothing. Over 239,670 files of third-party Go source, 5.05 GiB, the widening newly matches 3 blocks in 1 file, all three indented client-key: | scalars, and no new false positive. The clean corpus stays at 0.

Upgrading

Both halves move. The binary is what changed; the plugin manifest version is what makes claude plugin update deliver it.

curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.3.0/install.sh
sh install.sh
claude plugin update spill-guard

Windows, in PowerShell:

curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.3.0/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1

Two steps rather than one on purpose. The one-liner that pipes a remote script into a shell works, and for a tool whose whole pitch is that nothing leaves your machine, opening with "pipe thi...

Read more

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 04 Sep 18:05
a81c87f

spill-guard keeps secrets and PII from reaching the API through a Claude Code session. It scans at the boundary between your filesystem and the model's context — the one a git secret scanner never sees, because a file read into a session is never committed.

Warning

This is a 0.x release and the surface carries no compatibility promise. Subcommand names, rule IDs, and the shape of a verdict can change in the next minor.

Caution

A buffer this build cannot decode is still allowed, not blocked, and reported in a notice. One case remains: a file behind a UTF-8 byte-order mark with a NUL in its first 8 KiB. What it does not read below has it, with the trigger that would close it. Nothing here is a wall against someone who means it — the escape hatch is one environment variable.

What v0.2.0 is for

Closing the gaps the v0.1.0 notes disclosed. That list is the commitment a reader can hold this project to without a checkout, so whether the scope held is read off the diff between the two What it does not read sections rather than off anyone's account of the release.

Six cases were disclosed there, plus one of a different kind added by errata. Five are closed, one is answered with its reason and stands, and one is deferred on an argument.

v0.1.0 disclosed v0.2.0
A block does not carry the unread-buffer notice Closed. Every verdict over a call carrying a buffer nothing read names that buffer, block or allow.
A binary-looking @ target in a prompt Closed on that surface. The prompt now matches the raw bytes. Read and Bash still skip them, on a measured cost.
Readers outside the three that are driven Closed as a class. Driven and answered per tool: a subagent load is already covered, a search tool and a skill load stay out for two different reasons, and an MCP file reader is the one member still undriven.
A secret synthesized at runtime Closed for env. A bare environment dump is refused on its shape, with the reason carrying the filtered form.
A recursive read Answered, and the refusal stands. A walk reads a different file set from the one the command would send.
A UTF-8 byte-order mark ahead of a NUL Deferred, on the argument below.
(errata) A scan the harness kills at 60 seconds Closed. The scan has a 45-second budget of its own and blocks at the end of it.

Highlights

  • A scan that runs long now blocks instead of vanishing. hooks/hooks.json gives this process 60 seconds; past that Claude Code kills it, discards the verdict, and the call proceeds with nothing in the transcript. The scan gets 45 of those seconds and writes a block at the end of them — the same verdict a buffer nothing could read already gets, downgradeable by an override like any other. Driven on built binaries: a 500 MiB fixture that took 68.130s on the build before this change, 8 seconds past the ceiling, blocks at 45.011s, while a 128 MiB one denies on the same rule either side.
  • A bare environment dump is refused on its shape. env writes values that exist only in the tool process, so at PreToolUse there is nothing to open and no rule can help. The call is refused instead, and the reason carries the filtered form so the model applies it and nobody is prompted. The bar it had to clear is that it must not fire on the careful form: 0 refusals over 21,252 real Bash calls, against 15 of 15 on planted dumps, with 5,380 of those real segments carrying a command name the rule knows. What decides it is position in the pipeline — env | cut -d= -f1 has a stage after it and env does not.
  • A binary @ target in a prompt is matched rather than skipped. @heap.dump arrives carrying the whole file, NUL included. The match loop now runs over the raw bytes on that surface and returns the skip reason beside the findings rather than instead of them. Over 4,000 real binaries — 1,414.7 MiB — the raw pass returned 0 findings at 17.4 MiB/s, and a 54-byte fixture holding a key returned 1, so that zero is a measurement rather than an absence.
  • A block carries the unread-buffer notice too. In v0.1.0 a call with findings in one buffer and an unread buffer in another reported the findings only, so the refusal's coverage sentence was true of what it read and not of the call. Which channel carries it is per event and was measured: a systemMessage survives beside a PreToolUse deny and is dropped beside a UserPromptSubmit block, where the block reason is itself shown to the person.
  • A rule's entropy ceiling reads the group's alphabet, not just its length — a precision fix in the matcher, not a new rule.

Upgrading

Both halves move. The binary is what changed; the plugin manifest version is what makes claude plugin update deliver it.

curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.2.0/install.sh
sh install.sh
claude plugin update spill-guard

Windows, in PowerShell:

curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.2.0/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1

Two steps rather than one on purpose. The one-liner that pipes a remote script into a shell works, and for a tool whose whole pitch is that nothing leaves your machine, opening with "pipe this into your shell" undercuts the claim before you reach the second paragraph. Download it, read it, run it.

Hooks are read when a session starts, so start a new one. Then run spill-guard selftest and read what it says it could not establish.

One behaviour change worth expecting. A Bash call whose scan outlives 45 seconds now blocks where it previously went through. Where that lands is not a fixed size: the measured rate on keyword-bearing text is 6.5 MiB/s on an M5 Max, which puts the crossing near 290 MiB in one call, and the same 45 seconds is 47 GiB of binary or 4.6 GiB of inert text. It scales with single-core speed, so a slower machine crosses lower. The refusal names the budget, and SPILL_GUARD_OVERRIDE=<reason> in command position downgrades it to a confirmation.

What it does not read

Every item here is measured, and each one is a case where a credential can cross. They are listed because a scanner's gaps are the only part of it a user cannot discover for themselves.

  • A UTF-8 byte-order mark ahead of a NUL. EF BB BF is a declaration the decoder has no arm for, so the buffer falls through undecoded, the NUL is read raw, and the file is skipped as binary. PowerShell's Out-File -Encoding utf8BOM, Notepad, and Visual Studio all write that mark. Deferred on its own argument rather than by oversight: the notice covers the whole residual class where a third decode arm would cover one member of it. What would revive it is a measurement showing the mark occurring organically on a machine this runs on — the census that exists today covers UTF-16 marks and found none decoding to binary. On a prompt the raw pass reaches such a file's contents anyway, which closes the consequence on one surface and none of the reason: a Read of the same file still exits 0 with a notice.
  • A binary buffer on Read and on a Bash operand. The raw match pass is the prompt's alone, and that is a cost decision rather than a policy one. 13 of the 21 binary Bash operands in the design corpus are the Claude Code binary itself — 306 MiB, 36.5 seconds of match loop against a 60-second timeout — where no organic binary @ target has been observed in 881 tokens. Both cases are reported in a notice.
  • A recursive read. grep -rn pat docs/ names a directory, and this reads files rather than walking them, so the call is refused rather than allowed. That refusal is now decided rather than pending. A walk reads a different file set from the one the command would send: ripgrep honours .gitignore and .ignore and skips hidden files where grep -r does none of that, so over one checkout rg --files reports 211 where a walk finds 11,815 and 1,132 findings in them. Blocking a call over content that was never going to cross is a false positive, and precision is the product. Name the files instead.
  • A secret synthesized at runtime, other than a bare environment dump. env is refused on its shape now. Anything else that produces bytes existing only in the tool process is unchanged and structural: there is nothing to open at PreToolUse and nothing to match, and PostToolUse cannot withhold a result.
  • An MCP server's file reader. The one member of the reader class still undriven. The other three are settled: a subagent load needs nothing, because the subagent's own tool calls fire the same hooks the parent's do; a search tool stays out, because nothing a PreToolUse hook opens can bound a walk over a tree; and a skill load names no file in its payload, so this is a member the design cannot resolve rather than one that is not there.
  • A command with no reader row contributes no operands. The per-command table decides which token of a segment is a path, and a command absent from it is scanned as a string and no further.

There is still no Homebrew tap and no Scoop bucket. Both are designed and neither is built, so a machine with neither cosign nor gh has no route that verifies for it — the install scripts refuse on such a machine rather than falling back to the checksum, because checksums.txt comes from the same place the archive did and answers corruption, not substitution.

What it does not finish

Closed. This section existed in the v0.1.0 notes because a scan still running at the 60-second timeout was killed, its verdict discarded, and the call allowed with no notice — the one disclosed crossing that told the person nothing, because the process is killed before it can write.

The scan now has 45 seconds and blocks at the end of them. The m...

Read more

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 06:44
eaf53e5

spill-guard keeps secrets and PII from reaching the API through a Claude Code session. It scans at the boundary between your filesystem and the model's context — the one a git secret scanner never sees, because a file read into a session is never committed.

Warning

This is a 0.x release and the surface carries no compatibility promise. Subcommand names, rule IDs, and the shape of a verdict can change in the next minor.

Caution

A buffer this build cannot decode is allowed, not blocked, and reported in a notice. A file behind a UTF-8 byte-order mark with a NUL in its first 8 KiB crosses unscanned, and so does a binary-looking file spliced in with @. What it does not read below has the list. A scan that runs past the hook's 60-second timeout is a separate crossing and carries no notice at all, because the process is killed before it can write one: What it does not finish. Nothing here is a wall against someone who means it — the escape hatch is one environment variable.

Highlights

  • Three surfaces, each driven against a real hook rather than reasoned about. A prompt (including the @path tokens that splice a file into context with no hook of their own), a Read call's file_path, and a Bash command — both the command string and the files its reader operands resolve to.
  • It fails closed, except against the clock. The sibling guards fail silent so a hook on every call never breaks ordinary work. This one inverts that: an operand it cannot resolve, a path that is not a regular file, an internal error — each blocks with a reason. A scanner that fails quietly reports a safety it is not providing, which is exactly what a hook killed at its 60-second timeout does. What it does not finish has that one and the measurement behind it.
  • Empty supply chain. go.mod carries no require block at all. The build graph is 116 packages, all of them this module plus the standard library, and it reaches no net, net/http, or os/exec. Three gates enforce that on the import graph rather than at review, because a telemetry field added in good faith is the failure mode here.
  • Precision over recall. 14 rules, 10 on by default and the 4 PII rules off. The ruleset this replaces flagged 27.6% of real files with zero true positives, which is how a security tool trains everyone to ignore it. Every rule ships with a corpus case proving it stays quiet on clean input.
  • spill-guard selftest answers whether the binary actually scans. Eight arms through the real hook path in-process, three of them allowing arms, so an over-matching rule shows up as a failure rather than as more green.

Installing

Two halves, and neither is any use alone. The plugin is the wiring that 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 and names the fix in the refusal.

curl -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.1.0/install.sh
sh install.sh
claude plugin marketplace add karlkfi/claude-spill-guard
claude plugin install spill-guard@spill-guard

Windows, in PowerShell:

curl.exe -fsSLO https://github.com/karlkfi/claude-spill-guard/releases/download/v0.1.0/install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1

Two steps rather than one on purpose. The one-liner that pipes a remote script into a shell works, and for a tool whose whole pitch is that nothing leaves your machine, opening with "pipe this into your shell" undercuts the claim before you reach the second paragraph. Download it, read it, run it.

Hooks are read when a session starts, so start a new one. Then run spill-guard selftest and read what it says it could not establish — whether Claude Code is invoking the hook at all is not reachable from outside a session, and the report says so rather than letting a green run imply it.

What it does not read

Every item here is measured, and each one is a case where a credential can cross. They are listed because a scanner's gaps are the only part of it a user cannot discover for themselves. Every one of them is a limit on what the scanner reads. What it does not finish is the other kind, and it is not in this list.

  • A UTF-8 byte-order mark ahead of a NUL. EF BB BF is a declaration the decoder has no arm for, so the buffer falls through undecoded, the NUL is read raw, and the file is skipped as binary. PowerShell's Out-File -Encoding utf8BOM, Notepad, and Visual Studio all write that mark. The call is allowed with a notice naming the buffer. The notice reports that nothing looked; it is not coverage.
  • A binary-looking @ target in a prompt. @heap.dump arrives carrying the whole file, NUL included, and the scan pipeline skips any buffer with a NUL in its first 8 KiB. Same notice, same consequence.
  • A recursive read. grep -rn pat docs/ names a directory, and this reads files rather than walking them, so the call is refused rather than allowed. That is the fail-closed policy working, and it means recursive greps do not run while the hook is installed. Name the files instead. Whether to walk the directory is undecided pending a measurement of what Claude Code does with a hook past its 60-second timeout.
  • A secret synthesized at runtime. env, and anything like it, produces bytes that exist only in the tool process. There is nothing to open at PreToolUse and nothing to match, and PostToolUse cannot withhold a result, so this is structural rather than a missing rule.
  • Readers outside the three that are driven. An MCP server's file reader, a search tool that returns matching lines, a skill or subagent load. Each is a hook input naming a path whose result comes back as content, and none of them has been driven. The gap is stated rather than closed because the tool set is not a list anyone can enumerate once.
  • A block does not carry the unread-buffer notice. A call with findings in one buffer and an unread buffer in another reports the findings only, so the coverage sentence in the refusal is true of the buffers it read and not of the call.

There is also no Homebrew tap and no Scoop bucket. Both are designed and neither is built, so a machine with neither cosign nor gh has no route that verifies for it — the install scripts refuse on such a machine rather than falling back to the checksum, because checksums.txt comes from the same place the archive did and answers corruption, not substitution.

What it does not finish

Added to these notes on 2026-09-03, after this release was published. What it does not read reads as the whole set of ways a credential can cross, and it is not the whole set. This is not a seventh item in that list. Those are gaps in what the scanner reads, and where a buffer goes unread the call is allowed with a notice naming it. Here nothing goes unread: the scanner has the secret in hand when it is killed, and nothing it was about to write survives. A fixture carrying a planted AWS key at its midpoint denies at 380 MiB and is allowed through at 400 MiB.

hooks/hooks.json gives the hook 60 seconds. Past that, Claude Code kills the process, discards whatever verdict it was about to write, and the call proceeds — on both events, driven against Claude Code 2.1.251. A killed process writes nothing, so neither shape that produces a block is available to it: not the deny object, not exit 2. It cannot even write the notice it writes for a buffer it declined to read. Nothing this build ships can turn an expiry into a block.

Something is written down, and not where a notice would be. Claude Code writes a hook_cancelled entry into the transcript carrying "timedOut": true and the elapsed milliseconds. That entry reached neither the --output-format json result nor stderr, and the model's own answer named no hook, so nothing measured here shows it arriving at the model or at the person while the call is happening. Whether an interactive client renders it was not driven.

What reaches 60 seconds is about 390 MiB of text in one call, and that figure describes the machine it was measured on. The bracket: 380 MiB finishes in 58.297s and denies; 400 MiB is killed at 60. The rate is flat at 6.5 to 6.6 MiB/s across it. That is an Apple M5 Max with a warm page cache on Go 1.26.5, and it scales with single-core speed, so a slower machine crosses lower. A second measurement on a different fixture put the crossing nearer 410 MiB, so read it as 390 to 410 rather than as three significant figures.

The budget is the call's, not the file's. One Bash call of cat over four 128 MiB files runs 78.130s, and the largest file in it is 128 MiB. A per-file rule of thumb understates this by however many files a command names.

The expensive input looks harmless. A buffer with a NUL in its first 8 KiB is skipped as binary and costs nothing — 380 MiB of it is 0.039s. What costs 6.5 MiB/s is ordinary text carrying the ten default rules' keywords, one occurrence of each, anywhere in it. A 400 MiB log with a single AKIA in it and nine other literals scattered around costs the same as a file made of nothing but secrets.

This was measured on a binary built from this tag, not inferred from the trunk. The 380 MiB fixture takes 57.721s through a v0.1.0 build and 58.805s through the trunk build the other figures here come from. The crossing is this release's, not only the trunk's.

A recursive read above calls the timeout measurement pending. It was pending at the tag. It has been taken since, and that bullet stands as the record of what was known then.

There is no fix in this release. It needs a deadline inside the scanner, because a size cap answers before reading and cannot bound a call that names many files, and the budget has to be picked against a benchmark run on a slower machine than the one above.

The surface this freezes

Small on purpose, and `0....

Read more