Changelog
v0.8.1 (2026-08-18)
Maintenance
- Add a workflow to deprecate a published npm version from Actions (#219)
- Update pnpm to 11.22.0 (#220); lock file maintenance (#221, #223)
v0.8.0 (2026-08-16)
Breaking changes
- Two allow tags no longer add up. Tags were resolved per category with the first occurrence winning; the last tag now replaces the earlier ones whole.
[allow-secret] [allow-pii]resolves to the second tag alone, where in 0.7.0 it granted both —[allow-all]is how both are asked for. The change is the other direction being wrong:[allow-all]narrowed to[allow-secret]went on allowing PII, which is the opposite of what narrowing means and the unsafe one of the two possible mistakes - A tag written by anything other than a person no longer counts. A line the runtime writes under the user's role — a background task reporting back, most often — is skipped, so an agent's report that quotes
[allow-all]no longer lifts the guard for the next tool call. A workflow that relied on a tag arriving from a subagent's output has to write the tag in a prompt instead - A tag between two fenced blocks is read as quoted. Text from the first fence marker in a message to the last is quoting, where before the markers were paired off and the span between the second and third read as typed. A tag before the first fence or after the last still counts
Features
- Parse Bash commands as shell syntax rather than by splitting on whitespace. The tokenizer understands quotes (including
$'…'and$"…"), heredoc bodies, command and process substitutions, subshells, shell keywords, redirection operators and their file-descriptor prefixes. Several ordinary ways of naming a file were invisible to the old split: a path with a space in it,cat <secretswith no space, acaton the second line of a multi-line command,(cat secrets),while cat secrets; do :; done, andecho $(cat secrets) - Tokens record whether they came from a redirection operator or from a word, so a quoted
>is read as an operand.grep ">" secrets, an ordinary way to search a file for a>character, previously hadsecretsskipped as though it were an output target - Scan the value of a variable referenced through an expansion that carries a suffix, such as
${TOKEN:-fallback}or${TOKEN#prefix}. Only the bare$TOKENand${TOKEN}forms were recognised before - Expand the set of commands whose operands are treated as files written to stdout, from seven to around forty:
tac,rev,strings,xxd,od,hexdump,base64,cut,sort,uniq,shuf,column,paste,fold,fmt,pr,expand,unexpand,iconv, thez*catfamily,diff,comm,join,look, plus a second class whose first non-flag argument is a pattern or script and whose remaining arguments are files (sed,awk,grep,rg,ag,jq,yq). In-place editing is exempt:sed -i,perl -iandruby -i(including bundled forms such asperl -pi -eandperl -lpi) send the result back to the file and write nothing to stdout. A bundle is read one letter at a time, continuing only past switches that command accepts without a value — the letters differ per command, sosed -Eicounts whileperl -Ilib -peandperl -MList::Util -peare reads rather than in-place edits. A letter the list does not know stops the reading and the file is scanned.grep -iis unaffected — its-iis case-insensitive matching, and it still prints - Locate the command past a wrapper (
sudo,env VAR=1,timeout N,nice,xargs,stdbuf) and past a leadingVAR=valueassignment, so the wrapped command is classified instead of the wrapper - Parse and scan inline program text from
-c/-e/-pe, both as a nested command line and for the quoted path literals in it, which is what catchespython3 -c "open('.env').read()" - Scan the file operands of git subcommands that print contents (
show,diff,blame,annotate,grep,cat-file) and ofdd if=.git logcounts only when a patch is asked for (-p,--patch,-U<n>, and the merge-diff forms): without one it prints who changed the file and when, never a line of it - Scan every environment variable when a bare
envorprintenvwould print the whole environment, including behind a wrapper (sudo printenv) and when the output is redirected - Treat commands that only measure a file (
wc,cksum,md5sum,sha1sum,sha256sum) as non-reads, whether the file is named or fed in over< - Inspect the file inputs of every tool other than
ReadandBash,Grepand the MCP tools included. An input field naming an existing regular file is scanned before the call:path,paths,file,files,filepath,filename,filenames,absolutepath,notebookpathandsourcepath, compared with separators and case removed so thatfile_path,filePathandfilepathare one name. Beyond those names, any value containing a/is treated as a path whatever its field is called, which covers a tool carrying one undertarget,documentoruri. The/is what keeps a search pattern from being read as a path —{ "pattern": ".env" }searches for that text rather than reading the file — at the cost of missing a bare filename under an unlisted name. Found up to four levels down and inside arrays, of strings and of objects alike. A field naming a directory is left alone. Exempt are the tools that surface no file contents (Write,Edit,MultiEdit,NotebookEdit,TodoWrite,Glob,WebFetch,WebSearch,ExitPlanMode,AskUserQuestion) and tools whose name leads with a write verb (write_file,createPage): naming a file they do not read is not a leak. The default matcher becomesRead|Bash|Grep|mcp__.* - Add an opt-in integration test that runs the hook inside a real headless Claude Code session and asserts both halves of the block contract: the read is stopped, and the reason reaches Claude. Every other test spawns the hook and reads its output itself, which says nothing about whether the runtime acts on it. Set
SENSITIVE_CANARY_INTEGRATION=1to run it; it needs credentials and network, so CI skips it - The PreToolUse block reason is written to stderr instead of to a stdout
{"decision":"block"}payload. Both reach Claude on the current version, but the documentation describes stdout as ignored on a non-zero exit and takes the PreToolUse decision fromhookSpecificOutputrather than a top-leveldecisionfield, so the old form depended on undescribed behaviour. Blocking is unchanged: exit 2 is what stops the call - Heredoc bodies are treated as text, not commands: writing a script that mentions
.envviacat > deploy.sh <<EOFis not a read. Known limitation: a heredoc that feeds commands to a remote shell (ssh host <<EOF) is not caught, written up under "② PreToolUse hook" in the README - Detect a PEM private key that has been base64-encoded, under the new
private-key-base64rule.-----BEGINnever appears in the text, soclient-key-datain a kubeconfig,tls.keyin a Kubernetes Secret and a key held in Terraform state were all invisible to the plaintext rule. Three bytes encode to four characters, so the header looks different depending on where it sits relative to that boundary: the rule carries all three forms, since matching one would find one key in three. Swept over 986 real files on a developer machine, it found four keys in a kubeconfig and nothing else - Detect credentials in the userinfo half of an http(s) URL, under the new
url-basic-authrule — a git remote, a.netrc, a private registry, acurlinvocation. RFC 3986 §3.2.1 deprecates the form for the same reason. The placeholder machinery already covers the near neighbours, sohttps://user:password@localhost,https://x-access-token:${GH_TOKEN}@…andhttps://USERNAME:PASSWORD@example.comstay quiet; the same sweep of 986 real files flagged none of them - Count the hash in a Telegram bot token loosely. The pattern asked for exactly 33 characters after
AA— not a minimum, an exact count — so a 32-character token and a 35-character one were both invisible, and the bot id was capped at ten digits. Now 6–12 digits and 30–40 characters, with word boundaries at either end - Read
mongodb+srv://as a connection string. The rule listedmongodbbut not the SRV scheme, which is the one MongoDB Atlas hands out - Recognise the AWS key prefixes
ABIA,ACCA,APKAandASCAalongside the nine already listed
Fixes
- Read a file both ways when its encoding is a guess. Eight NUL pairs — sixteen bytes — in front of a UTF-8 file were enough for
detectUtf16to call it UTF-16, and the rest of it then decoded into characters no rule matches. The counts cannot separate the two cases: a UTF-8 file with a few NULs on one side of its pairs looks exactly like a page of Japanese UTF-16, where一(U+4E00) puts a NUL on the minority side. A verdict that did not come from a byte-order mark is marked as a guess and both readings are scanned. The same cap was excluding real documents: thirty lines of Japanese with one一apiece were not read as UTF-16 at all - Keep an
.envin a swept directory whatever its bytes look like. The sweep skips binaries so that a folder of images is not ground through every rule, and that skip ran before the name guard, so eight bytes of NUL at the head of a.envtook the strongest guard in the tool out of the sweep - Do not honour a tag written by anything other than a person. A background task reporting back arrives under the user's role carrying an agent's prose, and prose about these tags was enough — a report quoting the documentation armed the guard it was describing. The transcript says which lines are which, and that is what the reader asks now
- Read the run from the first fence marker to the last as quoted. Pairing the markers off left the span between the second and third readable as typed, and a pasted markdown document with a code block inside it puts a quoted tag in exactly that span
- Scan somewhere when a search names no path.
rg pattern,grep -r patternandGrep {pattern}with nopathall print from the working directory, and with no field to collect there was nothing to scan. Judged on names alone: a directory nobody named is every repository anyone searches, and reading their contents stopped a plainrg TODOin four of twelve checkouts - Name
NotebookReadin the hook matcher. It reached the hook only becauseReadis a substring of it, and thenotebook_pathfield the hook reads was being served by that accident: anchoring the match, or a rename, would have dropped notebooks with nothing to say so - Count one finding per category rather than per value. A value that a secret rule and a PII rule both match is two findings, and collapsing on the value alone reported whichever came first — so the block named one category while the other was what held it, and which tag lifts it read as arbitrary
- Stop the search for the wrapped command at a command that prints its arguments.
sudo echo cat secretsresolved to thecatsitting in echo's arguments and scanned a file the command never opens, soecho,printf,true,falseand:end the descent - Leave the output file of
sort -o out.txt in.txtand its siblings (shuf -o,iconv -o,tee) out of the scan. The operand a flag names as a destination is written, not printed, so naming it is not a read - Correct the boundaries of six rules. Discover's
65range stopped at 6589; the card alternatives all assumed groups of four, where Amex prints 4-6-5 and Diners 4-6-4; Square's exact length meant one character over the guess stopped the token matching at all rather than matching partly;twilio-sidhad no word boundary, so a certificate fingerprint was an Account SID;telegram-bot-tokencapped its secret part at 40 and went invisible at 41; andpii-emailexcludedzipat the TLD position as though a list of file extensions were a list of domains - Stop treating every dotted value as a reference to code.
isNotSecretShapedwaved through anything shapeda.b.c, and dotted credentials exist. What separates them is that a name is words: measured over 147,643 dotted identifiers from source on this machine, 0.06% fall below a mean word length of 2.5, and the ones that do are JWTs - Redact by code point. Slicing by code unit cut a surrogate pair in half and wrote a lone surrogate to the terminal
- Match the placeholder rule's connection-string pattern once rather than three times, and bound its scheme: an unbounded
\w+in front of a literal that is usually absent is quadratic in the length of a value someone else writes - Make the email rule near-linear on its worst input. The local part (
[A-Za-z0-9._%+-]+) spans the word boundary at every dot, so on a long run of digits and separators with no@— a log full of IP addresses or version numbers is exactly that — every boundary cost a greedy consume of the rest of the text plus a character-at-a-time backtrack in search of the@: O(n²), half a minute for 200 KB, and effectively forever for a multi-MB file. The local part is now bounded at 64 characters (RFC 5321's limit, so no deliverable address is lost) and the domain is matched as dot-separated labels, which leaves nothing to backtrack over - Bound the
connection-stringcredentials too.[^@\s]+crosses both:and/, so a line ofmongodb://with no@in it ran to the end of the text from every occurrence: 188 KB took 2.3s, and 1 MiB through the hook took 98s and returned exit 0. Every adversarial shape then in the tests walked past it, which is the shape list being caught short rather than the guard working, so one written for this syntax was added - Bound the
env-assignmentpattern's name the same way. It read[A-Z_]*before its keyword and[A-Z_0-9]*after, so a run of capitals with no=backtracked from every position: 59 KB took 381ms, 234 KB 6.9s, 1 MiB 125s. 1 MiB is what the file cap allows through, so capping the read did not stop the hook being killed — measured, a 1 MiB file of repeatedSECRETwas still killed at 40 seconds with the cap in place. Every rule in the config is now run against a list of adversarial shapes in the tests, so this shape fails before a release rather than after one - Read a file into a buffer of the cap's size rather than of the size
statreports. procfs and sysfs entries are regular files that report zero bytes and produce content anyway, so their content was read as empty and passed.readFileSync, which this replaced, read to EOF and did not have the problem. Reading such a file is not the same as scanning it whole: the NUL rule stops at the first separator, so/proc/self/environis read and only its first variable is looked at, which is now listed under Known Limitations - Scan only the first 1 MiB of a file rather than reading it whole.
readFileSynchas no size limit, so a large enough file kept the hook from ever returning — and a hook killed by Claude Code's PreToolUse timeout does not block the call, which made the hang a way through. A secret past the cut is missed, the same trade the transcript's 64 KB tail read already makes - Scan the file operand of a pattern-first command when the pattern flag carries its value written against it.
grep -eaws secrets,grep -faws secretsandsed -e's/a/b/' secretsscanned nothing: the attached spelling was not recognised, so nothing marked the pattern as supplied and the file that followed was consumed as the pattern. The separate (grep -e aws) and=(--regexp=aws) spellings were already handled - Put back what quieting the rules had taken out. A corpus of five hundred generated values, run against this release and against v0.7.0, found a hundred and twenty-seven inputs the old version detected and this one did not — none of which the thirty-two cases chosen by hand had caught. Restored:
- an address near an excluded word. One word within a couple of dozen characters was erasing every address near it, three at a time in a CSV. The exclusion is now the three shapes that are really hostnames: a VCS user, an address straight after
ssh/scp/rsync/sftp, and thehost:pathform - a bare private address. Requiring a label lost
192.168.1.50,X-Forwarded-For: 10.0.0.5andremote_addr=…; what says an address is a machine is the command around it, so that is what excludes it now, and ahost:portpair is a service rather than a person - an assignment that is not at the start of a line:
docker run -e PASSWORD=…,cd /app && PASSWORD=…, a single-quoted value, a value with a trailing semicolon or comma, and one indented past sixteen columns.DB_PASScounts as well asDB_PASSWORD - a Square token after
key_or in a query string, which a boundary counting_and=as base64 had erased - the Korean resident and business numbers without their separators, which is how they are stored. Context keeps a timestamp out instead
- a postal code next to the word
max, and a connection string whose password runs past 256 characters
- an address near an excluded word. One word within a couple of dozen characters was erasing every address near it, three at a time in a CSV. The exclusion is now the three shapes that are really hostnames: a VCS user, an address straight after
- Read a command that arrives as an argv array on the
Bashtool too, not only on an MCP one. The same command was scanned or not depending on who sent it - Block a
.envtemplate whose contents cannot be read whole. The exemption assumed the contents would be scanned instead, and a NUL byte or a file past the per-file cut stopped that — so.env.nul.exampleand.env.big.examplepassed on their names after all - Expand
**as a single*rather than refusing it. Refusing it meantcat **was scanned not at all, while the shell expanded it and read the files - Read a command field that arrives as an argv array or nested under another key. Only a top-level string was read, so
{"command":["cat",".env"]}and{"args":{"command":"cat .env"}}went past — both by a name with no slash in it, which the path rules do not collect either - Stop reading after five seconds. A byte budget bounds what is read and not what is walked, and a pattern reaching one level under a home directory took ten seconds, which is close enough to the PreToolUse timeout to matter
- Stop blocking ordinary work. Measured over sixty-four commands from a working day, the hook blocked sixteen of them; it now blocks five, and four of those five are this repository's own README and changelog, which contain an AWS-shaped key as documentation. What changed:
- an address is not a person when an
ssh,scp,rsync,cloneorgit@is next to it, andexample.comand the other RFC 2606 domains are nobody's mail - the published test card numbers are not cards
capis an English word as well as an Italian postal one, so sizes and limits nearby say it is not a postal code- the Korean resident and business numbers are written with their separators; without that, a millisecond timestamp in a log was a finding
- a Square token inside a longer run of base64 is a slice of something else, which is what made
cat ~/.ssh/known_hostsa finding - a value that is a variable reference (
PASSWORD: ${VAR}) names a secret rather than being one .env.example,.env.sample,.env.template,.env.distand.env.defaultsare not blocked by name. Their contents are still scanned, so a template with a real key in it is still caught — by what is in it
- an address is not a person when an
[allow-secret]lifted PII blocks, which the README says it cannot. Deduplication ran before the allow tag, and it keys on the value — so a string that a secret rule and a PII rule both match lost the PII finding first, and the tag then removed what was left. The two hooks had the order the other way round from each other; the prompt hook was right- A pasted log could lift the guard on the key in the same message. The prompt hook read tags from the raw prompt while the other hook read them from what the user typed, so a fenced log or a README quoting
[allow-secret]decided them. One implementation now answers for both - Input the check could not read was treated as input the check approved. Two characters missing from the end of a payload passed a key through. Empty stdin is still nothing to check; bytes that will not parse now stop the call
- A filename could put lines into the text Claude reads. POSIX allows a newline in a path and a path is attacker-chosen, so a file could be named such that the block message grew a line saying the block was a false positive. Escape sequences went the same way and could clear the screen first. Control characters are escaped on the way out now, and the finding list is capped — one rule that matched everywhere produced forty thousand lines
- A single rule from a config file could hang the hook. The scan budget is checked between rules and cannot interrupt one match, so
(a+)+$ran for hours and the hook was killed — which does not block. A V8-side timeout does interrupt a running match, at 0.06ms per scan - A config path that is a FIFO blocked the read forever, and a config with more than about 120,000 rules threw while the module was still loading, before any handler existed. Both exited without blocking
tailprinted the part that was not scanned. The per-file cap reads the first megabyte;tail -2 app.logshows the last lines, which on a large log is where a failure has just printed a connection string. Both ends are read now. What is still missed is the middle of a file larger than both windowsviewandvimdiffprint a file the waylessdoes and were not on the list- The documentation said the hooks are active immediately after installing the plugin. A session that is already running does not pick them up: it reports the plugin as enabled and checks nothing. It also said a PreToolUse allow tag is consumed by the first tool call — it lasts until a tool result is recorded, so calls issued together are all covered by one. And it said a
.envwith an allow tag is passed through without scanning, which is the opposite of what the code does. All three are corrected, and the step that proves a hook is really running is now on the recommended install path rather than only the pnpm one - The
phone-jpvalidator existed and was named in neither document; a test now holds both documents to the registry. Added a section on the ways a rule goes quiet without warning —secretGroup: 0is not the same as omitting it, anentropyThresholdabove 8 rejects everything,flags: "y"matches only at the start of the text, and a largecontextWindowwidensexcludeContexttoo - The same defect was still in
env-assignment, and worse. Its value capture was open-ended, so a megabyte ofTOKEN=TOKEN=…took six minutes — past any hook timeout, and a killed hook does not block. The capture is atomic now ((?=(X))\1, since every character the delimiter test accepts is one the class already excludes, so retrying a shorter run could never succeed) and capped, with a single character deciding whether the value simply ran past the cap. 373 seconds to 2 milliseconds, and a fifty-thousand-character value is still found - The hook stopped every tool call, with no way out, when its working directory had been removed.
process.cwd()throws there, and it was called while the module was still loading — before the transcript is read — so the message advising an allow tag described something that could not be honoured. A build script that runsrm -rf distfrom insidedist, or agit worktree remove, is enough. There is nothing sensitive about a missing directory: a relative path simply has no base - A tag written in backticks did not work, and the documentation writes them that way. Treating inline code as quoting refused the form this project teaches, and refused it silently — the block that followed advised adding the tag it had just ignored. Fenced blocks still quote rather than issue, so a pasted log cannot lift the guard
<bash-input>was missing from the elements that are not user input, an unclosed element was not stripped at all, and a line the runtime wrote as an assistant turn was read as user input if the message inside it claimed the role- A UTF-16 file whose first characters are Japanese or Chinese has no zero byte among them, and five hundred pairs of prefix decided the whole file. The window is wider, the threshold is on the asymmetry rather than the rate, and whether the result reads as text is what settles it
- A FIFO named as the transcript blocked the read forever; a write to a closed stderr threw on the way out of a block and turned it into a pass; and a payload of
nullparsed successfully and then threw on the first field read - Twenty-six wrong blocks out of six hundred real files. A value that is a URL, a path, an identifier, a header name, a number or a dotted setting name is no longer read as the secret its variable is named after —
secret_name,VAULT_TOKEN_PATHandTOKEN_HEADER_NAMEdescribe a secret rather than holding one, and a key whose last word isPROJECTorENDPOINTsays so outright. The shape test applies only where a rule captured a free-form value: a Slack webhook is a URL and a secret both, and asking whether it looks like a URL is the wrong question - A connection string with
${PGPASSWORD}still in it holds no credential at all, andpostgres:postgres@is what a compose file ships with - A context word is a label, not a fragment of the identifier beside the number.
extract-zipsupplied "zip" andgolang.org/x/mobilesupplied "mobile", so a version number beside either read as a postal code or a telephone number — which is to say lockfiles andgo.sumcould not be read at all. Nor couldname@version, which is an address by shape - Twelve identical digits satisfy the My Number checksum by arithmetic rather than by being anyone's number, and
01-02-2024is a date. A Japanese telephone number has ten digits or eleven, and 0120 belongs to a business - A megabyte of
eyJused to kill the hook, and a killed hook does not block. Two rules were shaped{n,}followed by a literal that may never come, which makes the engine retry the whole tail from every start position.eyJrecurs every three characters, so one 400 KiB file was enough to spend the PreToolUse timeout and take the rest of the call with it — including the.envname guard, by naming the padding first. A JWT begins at a token boundary, and saying so leaves one start instead of a third of a million: a megabyte went from 104 seconds to 27 milliseconds. The Mapbox, Sentry and Square patterns had the same shape and are bounded too - A scan that runs past ten seconds now stops the call rather than finishing quietly. Bounding those patterns fixed the two rules that could do it; this is so the next rule of that shape is caught instead of repeating it. The check sits between rules, since a single match cannot be interrupted
- An allow tag could be issued by something other than the user. Claude Code records the output of a
!command, slash-command names and system reminders as user messages, so[allow-all]appearing in any of them lifted the guard for the next tool call —grep -r allow-allwas enough. A tag inside a code fence no longer counts either: a pasted log is quoting the tag, not asking for it - A UTF-16 file was not scanned at all. Every other byte is NUL, and the scan stops at the first one, so the contents came to one character. PowerShell 5.1 writes UTF-16LE by default, which makes redirecting a command's output to a file a way past this. Little-endian, big-endian and byte-order-marked files are all read now; genuinely binary files are still left alone
Readwith afile_paththat is not a string exited 0, while the same shape under any other tool name reached the shared collector and blocked- A crash no longer passes the call through. Only exit 2 blocks, and an unforeseen error exits 1 — so any bug anywhere in a hook silently switched the protection off, which is the failure this tool exists to prevent. Both hooks now stop the call instead, with a message saying the check did not finish rather than claiming a finding. Input the hooks do understand is unaffected;
[allow-all]gets past it - A prompt that is not a string is read rather than dropped. Not throwing on
{"prompt":{"text":"…"}}was only half the fix: coercing it to the empty string exited 0, which is the same silence the exception produced. Every string inside the value is collected now, to a bounded depth, so object, array and content-block prompts are scanned like a plain one - A field named
command.lineorcommand linewas walked past whilefile.pathwas read correctly — the two collectors normalised field names with a regex each, and the one for commands dropped only-and_ - The placeholder recognition added above could be used to smuggle a live credential: it asked whether a value contained a placeholder word, so
changeme_in front of a real key switched the rule off. The whole value has to be placeholder now - Widening the Stripe and OpenAI rules swallowed two rules whole:
stripe-restricted-keybecame a strict subset ofstripe-secret-key, andopenai-project-keystopped being reported at all. Both fire again - Private IPv4 addresses are no longer detected. An RFC 1918 address is non-routable and identifies nothing outside the network it belongs to, and the rule spent its time on ansible inventories, ssh configs, Kubernetes manifests and docker-compose files — five such files, all blocked before, all quiet now. Public addresses are unchanged and still require a nearby label. Anyone who wants the old behaviour can add the rule back through the config file; the
excludeContextfield it used is documented now and still serves the postal code rule - The release could not publish. GitHub runs every
run:step asbash -e {0}, and the smoke test added last round pipes into a hook that exits 2 on purpose, so errexit killed the step before the assertion that expected the 2. The gate written to make the release safer made it impossible; every invocation now captures its status instead of letting the pipeline decide the step's fate - The release gates now run against the tarball
npm publishwould upload, not the checked-out tree. Deleting"dist/"from thefilesfield used to pass every gate while shipping a package whose hooks cannot start — verified by doing it, along with droppinghooks/and shipping the tests hooks/hooks.json— the file the plugin install path reads, and the only one still pointing at the TypeScript sources — had no gate at all. Emptying it left every check green. The release now parses it, requires both events, and resolves every command's path inside the tarball- Recognise a value written to be replaced. Half of a realistic
.env.examplewas blocked on its contents (your-password-here,REPLACE_ME_WITH_REAL,django-insecure-...,postgres://user:password@localhost/db), which defeats exempting the name: the file exists to be committed and read. Ten realistic templates now read clean, and one holding a live key is still blocked. Only secret rules consult the list, andexampleis deliberately not on it — AWS's own documented key ends in it and is still a key - An address stopped being found when a remote-shell word appeared anywhere within forty characters:
rsync failed, notify alice@corp.iowas silently dropped. The exemption now covers the operand position only —ssh user@hostand at most two arguments in between — and thehost:pathforms of scp and rsync are left to the trailing-colon rule that already handled them - Cover the credit card brands the rule claimed and did not match. The Discover branch required seventeen digits, so no Discover, JCB or Diners card could reach it, and Mastercard's 2-series (2221-2720) and UnionPay were absent outright — five brands undetected. Ranges follow Discover's published IIN summary, which also puts the Discover range at 644-658, so 659 is no longer claimed
- Slack's rotated tokens (
xoxe-), app-level tokens (xapp-) and workflow tokens (xwfp-) were not matched; nor were Stripe restricted, organization and webhook-signing secrets, nor eight of GitLab's ten token prefixes - Mapbox and Sentry tokens were written to shapes those services do not issue — Mapbox delimits into three parts of which the first is the literal
pk,skortk, and a Sentry org token is underscore-separated, not dotted. Neither rule had ever matched a real token - A Square token longer than sixty characters was missed. Square's contract allows up to 1024; the length had been pinned at exactly what appears in the wild
- Codice fiscale: omocodia substitutes letters for digits at the seven numeric positions when two people would share the first fifteen characters, and both the pattern and the checksum guard demanded digits there — so every such code, each issued to a real person, was missed
- Add two more from a format survey: an Azure Shared Access Key (
SharedAccessKey=+ 44-char base64, for Service Bus, Event Hubs and IoT Hub, which is a different length from the 88-character storage account key) and a Google OAuth client secret (GOCSPX-). Google'sya29.access tokens and1//refresh tokens are deliberately not matched: Google documents no format for them beyond a size cap and reserves the right to change it, so a pattern would be a guess that reads as a guarantee - Add nine rules for credentials that no rule covered: OpenAI service-account and admin keys, Azure Storage account keys, Fly.io, Databricks, HashiCorp Vault, Shopify, Doppler, Grafana and Notion tokens. 64 rules to 73
- Stop repeating the blocked command back to Claude. The reason a block gives carried the first eighty characters of the command, so blocking
export GITHUB_TOKEN=ghp_…handed the token to the model inside the sentence explaining that it had been withheld. The detection lines were already redacted; the line above them was not - Read a command out of a tool input field. Only
Bashwas ever parsed as a command, so an MCP server that runs a shell —{"command":"cat .env"}— was looked at as a path, found not to be a file, and let through, with the default matcher sending everymcp__*tool down that path.command,cmd,scriptandcodeare read now, the last for the paths quoted inside it - Treat an input of the wrong type as absent rather than throwing. A
commandthat is a number, apromptthat is an object, acwdthat is an array: each threw, and an exception exits 1, which does not block —{"prompt":{"text":"<a key>"}}went through unscanned - Anchor the assignment rule to the start of a line and require its value to be a value. Widening it to
:and lower case made it read ordinary code:function check(token: ShellToken)was a secret, and the plugin could not read its own source — 97 findings across 17 files of this repository, now none - Resolve a relative path against the directory the tool runs in. The payload carries a
cwdand nothing read it, socat secrets.txtnamed a path relative to wherever the hook process happened to start and was dropped as a file that is not there. A literalcdearlier in the same command moves the base too, which is whatcd build && cat secretsneeds - Read an assignment written with
:and with a lower-case name. The rule wanted[A-Z_]and=, so adocker-compose.ymlfull ofPOSTGRES_PASSWORD: …, anappsettings.jsonwith"client_secret": …and an~/.aws/credentialswithaws_secret_access_key = …all passed — the three file shapes this tool exists to guard - Keep a substitution among the operands instead of ending the segment at it.
cat <(echo hi) secretsleftsecretsin a segment of its own, where it was read as a command name; the comment at that line said the only cost was reaching the inner command twice - Bound the work of one tool call at 64 MiB across every file it reads, and skip a file already read. A glob naming three hundred large files took half a minute, and five overlapping globs read the same files five times
- Lift the
.envname block only for a tag that allows secrets.parseAllowTagsreads[allow-<anything>], and the guard asked only whether any tag was present, so[allow-pii]and a mistyped[allow-pi]both turned it off. It no longer skips the content scan either: a tag for one category was silently covering the other - Expand
~and~/…to the home directory.cat ~/.aws/credentialsnamed a path that exists on no disk, so it was dropped as a file that is not there — and~/.ssh/id_rsa,~/.npmrcand~/.netrcwent the same way - Expand
{a,b}as well as*,?and[.cat .env{,.bak}reached the name guard as the single name.env{, which is not an.envfile, so the guard that reads names rather than disks did not fire - Keep the literal candidate beside a glob's matches. Returning only the matches was a way through this hook did not have before the expansion existed:
cat /nonexistent/.env.*matches nothing, so nothing was scanned and the.envname guard never ran, and a file really namedreport[2].txtwas read as a character class and expanded toreport2.txt - Read a shell's bundled
-c.bash -lc 'cat secrets'runs whatbash -cruns, and only the exact spelling was recognised, so the inline code went unparsed. The letters before thechave to be valueless switches - Step past
evalthe way the other wrappers are stepped past - Read
$(<secrets), which has no command in it at all: bash reads the file and substitutes its contents, so the redirection is the only thing there - Scan the quoted literals inside an awk or sed program.
awk 'BEGIN{while((getline l < "secrets")>0) print l}'names a file without ever passing it as an operand - Detect
-----BEGIN ENCRYPTED PRIVATE KEY-----and the SSH2 spelling, whichopenssl genpkey -aes256writes and the rule did not list - Expand a glob before deciding whether it names a file.
cat sec*collectedsec*, found nothing on disk by that name, and allowed the read;cat .env*did the same, one character away fromcat .env, which is blocked on its name. A pattern is now expanded and each match is scanned, up to 256 of them - Read a redirection that stands before the command.
< secrets catiscatreadingsecrets, but the operator was skipped and its target taken for the command name, so the real command went unclassified and nothing of it was collected — whilecat < secretsblocked - Scan the file named inside a
git log -Lrange.-L1,10:secretsprints the lines of that file, and the file is written inside the flag's own argument where neither the flag nor the operand handling would look for it - Read
--as the end of option parsing for the in-place test too. Insed -- -i secrets,-iis the script andsecretsis a file sed prints; read as the in-place flag, the command counted as writing and the file was skipped - Collect a path from an array inside an array.
{ "paths": [["…"]] }fell between the string branch and the object branch and was never looked at - Stop reading a path that names something other than a regular file. Reading
/dev/zeronever reaches the end of the file, so the hook did not return and Claude Code's PreToolUse timeout killed it — and a killed hook does not block the call, which made the hang a way through. The tool-input side already stat'd first; the Bash side now does too. On the paths that name a file outright —Readand a Bash command —.envand.env.*are still blocked on the name alone, before anything is opened. A tool input naming no existing file is left alone as before, since its "path" may be a URL route or an object key. What is no longer read is a FIFO, a process substitution or/dev/stdin, which is now listed under Known Limitations - Read
--as the end of option parsing.grep -- -aws secretssearches for-awsinsecrets, but-awswas taken for a flag, so nothing marked the pattern as supplied andsecretswas consumed in its place rather than scanned. Without the--the same tokens mean what they did before: the file is the pattern and the command reads stdin - Scan a variable named inside another expansion's suffix.
${A:-$TOKEN}prints$TOKENwheneverAis unset, but each expansion was matched whole, so the skip to the closing brace swallowed the suffix and the name in it. Every$a name follows now counts, which also takes in an unclosed${TOKEN: searching a checkout for template references withgrep -rn '${TOKEN' .is blocked when that variable holds a secret. A false block, and the same direction the hook already errs in forecho '$TOKEN' .claude-plugin/plugin.jsondeclared0.5.1whilepackage.jsondeclared0.7.0: the release checklist asks for both, and the bump was missed for 0.6.0 and 0.7.0. The plugin manifest now matches the released version- Also treat the rest of the digest commands as measuring a file rather than printing it:
sha512sum < secretswas scanned whilesha256sum < secretswas not, because only four of the family were listed.sha224sum,sha384sum,sha512sum,b2sum,shasum,md5andsumjoin them - Read a directory when a tool is pointed at one.
grep -r AKIA .and a Grep whosepathnames a folder both return file contents, and both reached a check that asks whether the candidate is a regular file, found it is not, and let the call through — with the key printed to stdout. The files directly inside are now scanned, one level deep and capped at the same limit a glob is. Measured over forty-five real directories, five block, and all five hold credential-shaped assignments - Scan what a command says, not only what it opens, for every tool rather than only
Bash. An MCP server that runs a shell takes{"command":"echo AKIA…"}, and the key was in the argument list unread - Scan every run of text in a file that holds NUL bytes. Scanning stopped at the first one, so a single leading NUL reduced the scan to the empty string — and an empty scan finds nothing and allows the read. The runs are joined by newlines so no rule matches across two of them
- Recognise UTF-16 when one side of each byte pair dominates, rather than when the other side is empty. Characters in the U+xx00 rows — U+3000, the ideographic space, among them — put a single NUL on the wrong side, and one of those in a Japanese document sent the whole file down the binary path
- Expand
$VARand${VAR}in a path.cat ~/.aws/credentialswas blocked andcat $HOME/.aws/credentialswas not, so the guard turned on which of two spellings the author used. A variable that is unset is left as written - Hold the scan budget across the whole hook invocation rather than resetting it per
scan()call. A hook scans once per environment variable and twice per file, so each call stayed inside the budget while the total did not: with a slow rule in a user config and sixty variables, measured at 29 seconds and exit 0. Now capped at 10.5 seconds, and past the budget it exits 2 - Fifteen detection rules matched a shape the vendor does not issue.
flyio-tokenrequired theFlyV1auth scheme, which is not part of the token, and excluded_and-from a base64url body;linear-key,twilio-sidandpostman-keywere lower-case only against mixed-case and hex formats;notion-token,digitalocean-pat,gitlab-pat,square-access-tokenandhuggingface-tokeneach covered one of the prefixes their vendor issues;replicate-tokenomitted the hyphen;azure-sas-keyfixed the length at 43 where Azure IoT DPS documents 16–64 byte keys;anthropic-keyasked for 95 characters after the prefix where the format has 101, truncating the match; andtwilio-sidhad no rule for the API Key SID at all - Start the scan clock when the payload arrives, not when the process does. Both the five-second file deadline and the scan budget began at module load and counted the wait for stdin against themselves, so a slow handover spent the whole allowance before a file was read: six seconds of delay and nothing was scanned, on an exit code of 0
- Stop a compaction summary and a skill body from carrying an allow tag. Both are written by the runtime with the user's role, and neither is anyone asking for anything: a summary re-injects earlier turns, so a tag discussed at any point in a conversation came back armed, and a meta line carries file content, so writing a
SKILL.mdwas enough to lift every check - Treat a fence that never closes as quoting, the way an unclosed synthetic element already was. A paste cut short is still a paste
- Return a quarter of a value in the block reason rather than eight characters of it. The reason is written to stderr, which is where Claude reads it, so it reaches the API the block exists to keep the value from — of a nine-character password, eight characters were being handed back
- Skip binaries when a directory is swept, while still scanning one in full when it is named outright. Nobody asked for the files a directory sweep picks up, and a folder of images cost three seconds and reported the compressed bytes as email addresses. Measured: 8 MiB of images, 3,103ms to 131ms
- Require a NUL imbalance to be near-total before reading a file as UTF-16. An eight-to-one ratio read four real binaries out of seventeen thousand as text, and a key sitting in a JPEG's bytes went unfound because the file decoded to nonsense
- Stop reading a reference to a value in code as the value.
env-assignmentmatchedprocess.env.API_TOKEN,user.password_digestandself.api_key; two in five of the distinct values it matched across thirty thousand real files were one of these - The last tag in a message is the one that applies, replacing the earlier ones rather than merging with them. Resolving each category separately kept the wider grant of the two, so
[allow-all]narrowed to[allow-secret]went on allowing PII — the opposite of what narrowing means. Two tags no longer add up:[allow-all]is how both categories are asked for - Resolve tags the same way in both hooks.
PreToolUsecollected every allow tag and never looked at mask tags, so[mask-secret] [allow-secret]stopped the prompt and then allowed the tool call it was stopping - Read AWS's documented keys as documentation. AWS writes
EXAMPLEwhere the random part would end —AKIAIOSFODNN7EXAMPLEand its siblings — and those appear in every setup guide and in every README that copies one, where a block reads exactly like a block on a live key. The newaws-keyvalidator rejects the suffix; a real key whose last seven characters spell it is one in thirty-six to the seventh. This project's own README, installation page and getting-started page were among the files it made unreadable - Stop reading a Retina asset filename as an email address.
logo@2x.pngsatisfied the pattern becausepngis two or more letters. Thirty asset extensions are excluded, none of which is a country code - Match card numbers at every length their brand issues. ISO/IEC 7812 allows 10–19 digits and the pattern encoded one length per brand, so 19-digit UnionPay, JCB and Discover cards, 13-digit Visa, and every Maestro card went unmatched. Swept over 986 real files, the wider pattern adds no false positive
Documentation
SECURITY.mddescribed the parse-error path as a fail-open that exits 0. It exits 2 — the security policy stated the protection backwardsdocs/troubleshooting.mdsaid hooks activate without a restart, which the README, the installation page and the getting-started page all contradict. Restarting is now the first step, since a session that missed the hooks lists the plugin as enabled and checks nothingREADME.mdsaid only the first 1 MiB of a file is scanned. Both ends are read; what is missed is the middledocs/rules.mdsaid any allow tag lifts the.envname block and passes the file through without scanning.[allow-pii]does not lift it, and the contents are scanned either waydocs/rules.mdgaveAPI_KEY=placeholderas a value the entropy threshold reports. It is not reported — the placeholder test drops it- The file structure in
README.mdlisted two files undersrc/lib/, from before this release added three more and moved the rules into JSON - The development commands in
README.mdwerenpm, whileCONTRIBUTING.mdand every CI job arepnpm. The lockfile is pnpm's, so following the README ignored it, wrote a second lockfile, and resolved a different tree from the one that is tested
CI
Publish compiled JavaScript. Node refuses to strip types from a
.tsfile insidenode_modules, so an npm install wired tosrc/started the hook, failed withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, exited 1 — and a non-zero exit that is not 2 does not block. The tool looked installed and checked nothing.dist/now ships besidesrc/, the npm instructions point at it, and no type stripper ortsxdownload is involved. The plugin install keeps using the sources, which sit outsidenode_modulesand workRun the release path's own gates.
release.ymlis reached by a push tomainand is not chained to the pull-request build, so a red branch could publish. It now runs the audit, the version-agreement check whose absence let the plugin manifest ship stale twice, a build, and a smoke test that starts both published hooks with plainnodeStop shipping the tests.
filescarriedsrc/, which carried__tests__: more than half the tarball, and none of it useful to anyone installingAdd a
versionsjob that fails whenpackage.jsonand.claude-plugin/plugin.jsondeclare different versions, or when either declares nothing that looks like oneCheck
vitest.config.tsthe waysrc/is checked. It sits at the repository root, and bothtscandbiomewere scoped tosrc, so the file that decides how the tests run was neither typechecked nor lintedRun CI on pushes to
mainanddevelop, not only on pull requests. The commit a merge makes belongs to no PR, so nothing built it: two branches that are green apart can still be red togetherThe release could not publish, again, and for a new reason. The smoke test now starts each hook through
eval, and under thebash -e {0}GitHub runs every step with, errexit fires inside the pipeline's subshell: a hook exiting 2 on purpose reached the assertion as 1, so all eight blocking assertions failed.set -uo pipefaildoes not clear-e, which arrives from the invocation. Measured: withoutset +e, eight errors and the step exits 1Make every step after the publish recoverable. The job was gated on
should_release, which npm alone decides, so a failure after the publish left the version on npm with no GitHub Release and no catalog sync, and a re-run skipped the job entirely. Only the publish is conditional now; the tag, the release and the sync are idempotent and run every timeRun the release smoke test against the commands
hooks/hooks.jsondeclares, as well as againstdist/. The manifest startssrc/*.tsunder type stripping, which is what a plugin install runs and what the gate only checked the existence of;dist/*.jsis what the npm instructions point at. The commands are read back from the manifest so the two cannot drift apartFail the release when the marketplace catalog does not pin this plugin to
main./plugin installserves the entry'sref, and with norefthat is the repository's default branch —develop. Every gate inrelease.ymlguardsmainand npm, and none of them was on the path a plugin user installs from, so a merge intodevelopreached users directlyAnchor the version shape test at both ends, in
ci.ymlandrelease.ymlalike.^[0-9]+\.[0-9]+\.[0-9]+with no$accepts anything at all after a valid prefix, andrelease.ymlsplices that value into fourrun:scripts:0.8.0"; curl … | sh; echo ",0.8.0 && rm -rf /and0.8.0$(id)were all accepted by the old test and are all rejected by the new one. The version is now validated in the job that captures it, before it reaches$GITHUB_OUTPUT, and every step that uses it reads it fromenv:rather than by interpolationCreate the git tag before publishing to npm, and let npm alone decide whether a version is released. npm refuses to republish, so a publish that landed and was followed by a failing step could not be retried — the tag it never created had to be made by hand, and the release job declined to act on a re-run
Give
release.ymlaconcurrencygroup, so two pushes tomainin quick succession cannot race over the tag and the publish. Nothing is cancelled: a release half-way through is worse than one that waitsPut a
timeout-minuteson every job in both workflows. A hung step otherwise holds a runner for the six-hour defaultAssert that the published
UserPromptSubmithook allows a clean prompt. Every assertion made of it was that it exits 2, so a hook that exits 2 unconditionally — blocking every prompt the user types — would have shipped greenInstall with
--frozen-lockfilein CI. A lockfile CI is allowed to rewrite is a lockfile CI does not checkTake CodeQL off GitHub's default setup and run it from
codeql.yml. Default setup only analyses a pull request whose base is the default or a protected branch, so a PR stacked on another feature branch was never scanned — the same gapci.ymlhad, and the branches it covers cannot be configured. The new workflow analyses pull requests, pushes tomainanddevelop, and a weekly schedule, because an advisory lands after a change merges rather than only alongside one. The two setups cannot coexist: default setup has to be off for these analyses to be acceptedFail the lint on warnings, and check
docs/.vitepress/the waysrc/is checked.biome lintexits 0 on a warning, so the deadtokenizeinsrc/lib/rules.ts— superseded bycontextTokens, and carrying a comment describing the behaviour that replaced it — sat in the tree reported and ignored. Two rules are turned off rather than obeyed:useLiteralKeyscontradicts this project'snoPropertyAccessFromIndexSignature, and applying it broke the typecheck;noTemplateCurlyInStringis off for the test tree, which cannot test${VAR}handling without writing one
v0.7.0 (2026-08-04)
Features
- Add multi-region PII detection rules (25 PII rules, up from 7)
- National IDs with checksum validation: Japanese My Number, French NIR, Italian Codice Fiscale, German Steuer-IdNr., Spanish DNI/NIE, Korean RRN and BRN, Chinese Resident Identity Card
- Phone numbers for JP, US, FR, IT, DE, ES, KR, CN
- Postal codes for JP, US/EU/KR (5/9-digit), and CN (6-digit)
- Public IPv4 and IPv6 addresses (reserved ranges excluded)
- Add context gating for noisy rules
- Rules with
requireContextonly fire when a nearby context word (phone, ZIP, IP, etc.) is found within a small window around the match (default: 3 tokens ≈ 24 characters) - Reduces false positives on bare digit sequences without sacrificing detection when labels are present
- Rules with
- Move all rule definitions to JSON (
src/lib/default-config.json)- Rules are now data, not code — the full set can be inspected and modified without editing TypeScript
- Checksum validators remain in code and are referenced by name from the config
- Add user-defined custom rules via config file
- Create
~/.config/sensitive-canary/config.jsonor setSENSITIVE_CANARY_CONFIGto a custom path - Add new rules, override built-in rules by id, and set a custom
contextWindow - Invalid rules are skipped with a warning; the rest of the config loads normally
- Create
- Expand secret detection coverage (39 secret rules, up from 24)
- AI services: Replicate, Hugging Face, Groq, OpenRouter, xAI (Grok), Perplexity
- Cloud / IaaS: DigitalOcean PAT, Supabase PAT
- Payment: Square access token
- SaaS / Dev tools: Mapbox, Sentry (user + org tokens), Atlassian, Linear, Postman
Fixes
- Fix My Number checksum: when the weighted-sum remainder is 0 or 1, the check digit is 0 (not invalid). Valid My Numbers ending in 0 were previously rejected.
- Correct spec source abbreviation: JIPTEC → J-LIS (地方公共団体情報システム機構)
- Harden
compileRule: forcegflag on regex, validateregexfield, warn on unknown validator name - Add strict schema validation for user-defined rules (required fields, optional field types, cross-field constraints)
- Pass
secretValue(not full match) to validator sosecretGroup+validateworks in user rules
Dependencies
- Update pnpm to v11.19.0 and refresh the lockfile
v0.6.0 (2026-08-02)
Features
- Add
SENSITIVE_CANARY_CATEGORIESenvironment variable to limit which rule categories are active- Accepts
secret,pii,secret,pii, orall(comma-separated, case-insensitive); unset/empty/invalid means all categories - Useful for reducing PII false positives (e.g. credit card or phone number rules firing on test fixtures) by scanning secrets only
- The name-based
.env/.env.*block is a secret guard and is disabled when thesecretcategory is not enabled
- Accepts
v0.5.3 (2026-06-27)
CI
- Rework the main→develop sync to open a PR with auto-merge, using a minted GitHub App token so the created PR triggers CI
- Disable persist-credentials in the sync workflow so the App token push works
Dependencies
- Pin pnpm via the
packageManagerfield and update pnpm to v11 (security) - Update node to v24, vite to v8, typescript to v6, and other dev dependencies and GitHub Actions
v0.5.2 (2026-03-31)
Security
- Add
minimumReleaseAgeto renovate.json to prevent supply chain attacks- Waits 7 days before auto-merging dependency updates
- Reduces risk of package takeover attacks
- Blocks immediate auto-merge of newly published packages
v0.5.1 (2026-03-15)
Fixes
- Remove
marketplace.jsonand sync marketplace viarepository_dispatchon release - Gate marketplace sync on actual release creation to prevent duplicate dispatches
- Update marketplace registration commands across README and docs to point to
coo-quack/claude-code-marketplace - Remove stale
marketplace.jsonreferences fromCONTRIBUTING.mdandREADME.md - Simplify backport workflow to direct main-to-develop merge
v0.5.0 (2026-03-14)
Features
- Add Google Cloud API Key (
gcp-api-key) detection rule - Add npm Access Token (
npm-token) detection rule
Fixes
- Prevent
openai-key(legacy) rule from overlapping withopenai-project-keyandanthropic-keyvia negative lookahead - Use nullish coalescing (
??) inentropy()for correct semantics undernoUncheckedIndexedAccess - Remove unreachable
uniquefilter inuser-prompt-submit-hook - Consolidate
randomBird()calls inblock()for consistent emoji across terminal and JSON output - Fix fd leaks in file read and
/dev/ttywrite paths withtry/finally - Use
bytesReadreturn value fromfs.readSyncto avoid NUL-filled buffer tails - Scan text prefix before first NUL byte in binary files instead of skipping entirely
Performance
- Read only the last 64 KB of transcript files for allow-tag resolution
- Skip binary content after first NUL byte to avoid pointless regex scanning
Documentation
- Unify documentation site structure with Getting Started and Troubleshooting pages
- Symlink
docs/contributing.mdto rootCONTRIBUTING.md
v0.4.6 (2026-03-12)
Security
- Add explicit permissions to all workflow jobs
- Resolve Dependabot security alerts via pnpm overrides
v0.4.5 (2026-03-12)
Fixes
- Scope CI badge to main branch
v0.4.4 (2026-03-12)
Improvements
- Migrate from npm to pnpm
- Add Renovate configuration with automerge on CI success
- Add pnpm version specification for GitHub Actions
Documentation
- Update install instructions from npm to pnpm
- Capitalize project title to Sensitive Canary across docs
Fixes
- Fix capitalization in project title
v0.4.3 (2026-02-23)
Documentation
- Replace Japanese text with English in npm install instructions
v0.4.2 (2026-02-23)
Fixes
- Scoped package name — renamed npm package from
sensitive-canaryto@coo-quack/sensitive-canary - Homepage — added
homepagefield pointing to the documentation site
v0.4.1 (2026-02-23)
Improvements
- npm publish automation — release workflow now publishes to npm with provenance on merge to main
- Package metadata — added
repositoryandfilesfields, removedprivate: truefor npm publishing - npm install docs — added
npm install -gsetup instructions to README and docs
v0.4.0 (2026-02-23)
Features
- Allow tags are now single-use — allow tags are consumed after the first tool call, preventing unintended persistent bypass across multiple tool uses in the same turn
Fixes
- Random bird emoji in block messages — PreToolUse block messages now use
randomBird()instead of a hardcoded emoji, matching the existing behavior in other messages
Docs
- README restructured — new section order: Why → Quick Start → What Happens → Detection Rules → How It Works → Allow Tags
- Docs site headings unified — "How It Works" → "What Happens", "What Gets Detected" → "Detection Rules" for consistency with README
v0.3.1 (2026-02-23)
Fixes
- Bird emoji in PreToolUse block reason — the bird emoji now appears in the block message shown by Claude Code, not only in the terminal output
v0.3.0 (2026-02-23)
Features
- Allow + Mask tag priority — when both
[allow-*]and[mask-*]tags appear in the same prompt, the first occurrence wins per category (secret,pii).[allow-all]and[mask-all]resolve both dimensions at once.
Fixes
- Plugin install command corrected to
sensitive-canary@coo-quack
v0.1.0 (2026-02-22)
Initial release.
Features
- UserPromptSubmit hook — scans every prompt for secrets and PII before it is sent to the Anthropic API
- PreToolUse hook — blocks
.env/.env.*files by name; scans file contents and Bash commands for secrets and PII - 25+ detection rules — AWS keys, GitHub/GitLab PATs, Stripe keys, Slack/Discord/Telegram tokens, JWTs, SendGrid/Mailgun/Mailchimp keys, Anthropic/OpenAI API keys, database connection strings, and more
- PII detection — email addresses, credit card numbers (Luhn-validated), US SSNs, US/JP phone numbers, Japanese postal codes, private IPv4 addresses
- Entropy filtering — suppresses false positives on low-entropy generic-secret and env-assignment matches
- Allow tags —
[allow-secret],[allow-pii],[allow-all]bypass specific categories per prompt - [mask-xxx] tag handling — explains that prompt masking is unsupported and suggests the correct allow tag
- Environment variable expansion — Bash commands referencing
$VAR/${VAR}have their env values scanned - Deduplication — repeated occurrences of the same secret value produce a single finding