@coo-quack/sensitive-canary 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +798 -0
  3. package/README.md +142 -45
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +155 -46
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +202 -365
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -289
  37. package/src/lib/__tests__/rules.test.ts +0 -1370
package/CHANGELOG.md CHANGED
@@ -1,5 +1,803 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.8.1 (2026-08-18)
4
+
5
+ ### Maintenance
6
+
7
+ - Add a workflow to deprecate a published npm version from Actions (#219)
8
+ - Update pnpm to 11.22.0 (#220); lock file maintenance (#221, #223)
9
+
10
+ ## v0.8.0 (2026-08-16)
11
+
12
+ ### Breaking changes
13
+
14
+ - **Two allow tags no longer add up.** Tags were resolved per category with the
15
+ first occurrence winning; the last tag now replaces the earlier ones whole.
16
+ `[allow-secret] [allow-pii]` resolves to the second tag alone, where in 0.7.0
17
+ it granted both — **`[allow-all]` is how both are asked for**. The change is
18
+ the other direction being wrong: `[allow-all]` narrowed to `[allow-secret]`
19
+ went on allowing PII, which is the opposite of what narrowing means and the
20
+ unsafe one of the two possible mistakes
21
+ - **A tag written by anything other than a person no longer counts.** A line the
22
+ runtime writes under the user's role — a background task reporting back, most
23
+ often — is skipped, so an agent's report that quotes `[allow-all]` no longer
24
+ lifts the guard for the next tool call. A workflow that relied on a tag
25
+ arriving from a subagent's output has to write the tag in a prompt instead
26
+ - **A tag between two fenced blocks is read as quoted.** Text from the first
27
+ fence marker in a message to the last is quoting, where before the markers
28
+ were paired off and the span between the second and third read as typed. A tag
29
+ before the first fence or after the last still counts
30
+
31
+ ### Features
32
+
33
+ - Parse Bash commands as shell syntax rather than by splitting on whitespace.
34
+ The tokenizer understands quotes (including `$'…'` and `$"…"`), heredoc
35
+ bodies, command and process substitutions, subshells, shell keywords,
36
+ redirection operators and their file-descriptor prefixes. Several ordinary
37
+ ways of naming a file were invisible to the old split: a path with a space in
38
+ it, `cat <secrets` with no space, a `cat` on the second line of a multi-line
39
+ command, `(cat secrets)`, `while cat secrets; do :; done`, and
40
+ `echo $(cat secrets)`
41
+ - Tokens record whether they came from a redirection operator or from a word, so
42
+ a quoted `>` is read as an operand. `grep ">" secrets`, an ordinary way to
43
+ search a file for a `>` character, previously had `secrets` skipped as though
44
+ it were an output target
45
+ - Scan the value of a variable referenced through an expansion that carries a
46
+ suffix, such as `${TOKEN:-fallback}` or `${TOKEN#prefix}`. Only the bare
47
+ `$TOKEN` and `${TOKEN}` forms were recognised before
48
+ - Expand the set of commands whose operands are treated as files written to
49
+ stdout, from seven to around forty: `tac`, `rev`, `strings`, `xxd`, `od`,
50
+ `hexdump`, `base64`, `cut`, `sort`, `uniq`, `shuf`, `column`, `paste`, `fold`,
51
+ `fmt`, `pr`, `expand`, `unexpand`, `iconv`, the `z*cat` family, `diff`, `comm`,
52
+ `join`, `look`, plus a second class whose first non-flag argument is a pattern
53
+ or script and whose remaining arguments are files (`sed`, `awk`, `grep`, `rg`,
54
+ `ag`, `jq`, `yq`). In-place editing is exempt: `sed -i`, `perl -i` and
55
+ `ruby -i` (including bundled forms such as `perl -pi -e` and `perl -lpi`) send
56
+ the result back to the file and write nothing to stdout. A bundle is read one
57
+ letter at a time, continuing only past switches that command accepts without a
58
+ value — the letters differ per command, so `sed -Ei` counts while
59
+ `perl -Ilib -pe` and `perl -MList::Util -pe` are reads rather than in-place
60
+ edits. A letter the list does not know stops the reading and the file is
61
+ scanned. `grep -i` is unaffected — its `-i` is case-insensitive matching, and
62
+ it still prints
63
+ - Locate the command past a wrapper (`sudo`, `env VAR=1`, `timeout N`, `nice`,
64
+ `xargs`, `stdbuf`) and past a leading `VAR=value` assignment, so the wrapped
65
+ command is classified instead of the wrapper
66
+ - Parse and scan inline program text from `-c` / `-e` / `-pe`, both as a nested
67
+ command line and for the quoted path literals in it, which is what catches
68
+ `python3 -c "open('.env').read()"`
69
+ - Scan the file operands of git subcommands that print contents (`show`, `diff`,
70
+ `blame`, `annotate`, `grep`, `cat-file`) and of `dd if=`. `git log` counts only
71
+ when a patch is asked for (`-p`, `--patch`, `-U<n>`, and the merge-diff forms):
72
+ without one it prints who changed the file and when, never a line of it
73
+ - Scan every environment variable when a bare `env` or `printenv` would print the
74
+ whole environment, including behind a wrapper (`sudo printenv`) and when the
75
+ output is redirected
76
+ - Treat commands that only measure a file (`wc`, `cksum`, `md5sum`, `sha1sum`,
77
+ `sha256sum`) as non-reads, whether the file is named or fed in over `<`
78
+ - Inspect the file inputs of every tool other than `Read` and `Bash`, `Grep` and
79
+ the MCP tools included. An input field naming an existing regular file is
80
+ scanned before the call: `path`, `paths`, `file`, `files`, `filepath`,
81
+ `filename`, `filenames`, `absolutepath`, `notebookpath` and `sourcepath`,
82
+ compared with separators and case removed so that `file_path`, `filePath` and
83
+ `filepath` are one name. Beyond those names, any value containing a `/` is
84
+ treated as a path whatever its field is called, which covers a tool carrying
85
+ one under `target`, `document` or `uri`. The `/` is what keeps a search
86
+ pattern from being read as a path — `{ "pattern": ".env" }` searches for that
87
+ text rather than reading the file — at the cost of missing a bare filename
88
+ under an unlisted name. Found up to four levels down and inside arrays, of
89
+ strings and of objects alike. A field naming a directory is left alone.
90
+ Exempt are the tools that surface no file contents (`Write`, `Edit`, `MultiEdit`,
91
+ `NotebookEdit`, `TodoWrite`, `Glob`, `WebFetch`, `WebSearch`, `ExitPlanMode`,
92
+ `AskUserQuestion`) and tools whose name leads with a write verb (`write_file`,
93
+ `createPage`): naming a file they do not read is not a leak. The default
94
+ matcher becomes `Read|Bash|Grep|mcp__.*`
95
+ - Add an opt-in integration test that runs the hook inside a real headless
96
+ Claude Code session and asserts both halves of the block contract: the read is
97
+ stopped, and the reason reaches Claude. Every other test spawns the hook and
98
+ reads its output itself, which says nothing about whether the runtime acts on
99
+ it. Set `SENSITIVE_CANARY_INTEGRATION=1` to run it; it needs credentials and
100
+ network, so CI skips it
101
+ - The PreToolUse block reason is written to stderr instead of to a stdout
102
+ `{"decision":"block"}` payload. Both reach Claude on the current version, but
103
+ the documentation describes stdout as ignored on a non-zero exit and takes the
104
+ PreToolUse decision from `hookSpecificOutput` rather than a top-level
105
+ `decision` field, so the old form depended on undescribed behaviour. Blocking
106
+ is unchanged: exit 2 is what stops the call
107
+ - Heredoc bodies are treated as text, not commands: writing a script that
108
+ mentions `.env` via `cat > deploy.sh <<EOF` is not a read. Known limitation: a
109
+ heredoc that feeds commands to a remote shell (`ssh host <<EOF`) is not caught,
110
+ written up under "② PreToolUse hook" in the README
111
+ - Detect a PEM private key that has been base64-encoded, under the new
112
+ `private-key-base64` rule. `-----BEGIN` never appears in the text, so
113
+ `client-key-data` in a kubeconfig, `tls.key` in a Kubernetes Secret and a key
114
+ held in Terraform state were all invisible to the plaintext rule. Three bytes
115
+ encode to four characters, so the header looks different depending on where it
116
+ sits relative to that boundary: the rule carries all three forms, since
117
+ matching one would find one key in three. Swept over 986 real files on a
118
+ developer machine, it found four keys in a kubeconfig and nothing else
119
+ - Detect credentials in the userinfo half of an http(s) URL, under the new
120
+ `url-basic-auth` rule — a git remote, a `.netrc`, a private registry, a `curl`
121
+ invocation. RFC 3986 §3.2.1 deprecates the form for the same reason. The
122
+ placeholder machinery already covers the near neighbours, so
123
+ `https://user:password@localhost`, `https://x-access-token:${GH_TOKEN}@…` and
124
+ `https://USERNAME:PASSWORD@example.com` stay quiet; the same sweep of 986 real
125
+ files flagged none of them
126
+ - Count the hash in a Telegram bot token loosely. The pattern asked for exactly
127
+ 33 characters after `AA` — not a minimum, an exact count — so a 32-character
128
+ token and a 35-character one were both invisible, and the bot id was capped at
129
+ ten digits. Now 6–12 digits and 30–40 characters, with word boundaries at
130
+ either end
131
+ - Read `mongodb+srv://` as a connection string. The rule listed `mongodb` but
132
+ not the SRV scheme, which is the one MongoDB Atlas hands out
133
+ - Recognise the AWS key prefixes `ABIA`, `ACCA`, `APKA` and `ASCA` alongside the
134
+ nine already listed
135
+
136
+ ### Fixes
137
+
138
+ - Read a file both ways when its encoding is a guess. Eight NUL pairs — sixteen
139
+ bytes — in front of a UTF-8 file were enough for `detectUtf16` to call it
140
+ UTF-16, and the rest of it then decoded into characters no rule matches. The
141
+ counts cannot separate the two cases: a UTF-8 file with a few NULs on one side
142
+ of its pairs looks exactly like a page of Japanese UTF-16, where `一` (U+4E00)
143
+ puts a NUL on the minority side. A verdict that did not come from a
144
+ byte-order mark is marked as a guess and both readings are scanned. The same
145
+ cap was excluding real documents: thirty lines of Japanese with one `一`
146
+ apiece were not read as UTF-16 at all
147
+ - Keep an `.env` in a swept directory whatever its bytes look like. The sweep
148
+ skips binaries so that a folder of images is not ground through every rule,
149
+ and that skip ran before the name guard, so eight bytes of NUL at the head of
150
+ a `.env` took the strongest guard in the tool out of the sweep
151
+ - Do not honour a tag written by anything other than a person. A background task
152
+ reporting back arrives under the user's role carrying an agent's prose, and
153
+ prose about these tags was enough — a report quoting the documentation armed
154
+ the guard it was describing. The transcript says which lines are which, and
155
+ that is what the reader asks now
156
+ - Read the run from the first fence marker to the last as quoted. Pairing the
157
+ markers off left the span between the second and third readable as typed, and
158
+ a pasted markdown document with a code block inside it puts a quoted tag in
159
+ exactly that span
160
+ - Scan somewhere when a search names no path. `rg pattern`, `grep -r pattern`
161
+ and `Grep {pattern}` with no `path` all print from the working directory, and
162
+ with no field to collect there was nothing to scan. Judged on names alone: a
163
+ directory nobody named is every repository anyone searches, and reading their
164
+ contents stopped a plain `rg TODO` in four of twelve checkouts
165
+ - Name `NotebookRead` in the hook matcher. It reached the hook only because
166
+ `Read` is a substring of it, and the `notebook_path` field the hook reads was
167
+ being served by that accident: anchoring the match, or a rename, would have
168
+ dropped notebooks with nothing to say so
169
+ - Count one finding per category rather than per value. A value that a secret
170
+ rule and a PII rule both match is two findings, and collapsing on the value
171
+ alone reported whichever came first — so the block named one category while
172
+ the other was what held it, and which tag lifts it read as arbitrary
173
+ - Stop the search for the wrapped command at a command that prints its
174
+ arguments. `sudo echo cat secrets` resolved to the `cat` sitting in echo's
175
+ arguments and scanned a file the command never opens, so `echo`, `printf`,
176
+ `true`, `false` and `:` end the descent
177
+ - Leave the output file of `sort -o out.txt in.txt` and its siblings
178
+ (`shuf -o`, `iconv -o`, `tee`) out of the scan. The operand a flag names as a
179
+ destination is written, not printed, so naming it is not a read
180
+ - Correct the boundaries of six rules. Discover's `65` range stopped at 6589;
181
+ the card alternatives all assumed groups of four, where Amex prints 4-6-5 and
182
+ Diners 4-6-4; Square's exact length meant one character over the guess stopped
183
+ the token matching at all rather than matching partly; `twilio-sid` had no
184
+ word boundary, so a certificate fingerprint was an Account SID;
185
+ `telegram-bot-token` capped its secret part at 40 and went invisible at 41;
186
+ and `pii-email` excluded `zip` at the TLD position as though a list of file
187
+ extensions were a list of domains
188
+ - Stop treating every dotted value as a reference to code. `isNotSecretShaped`
189
+ waved through anything shaped `a.b.c`, and dotted credentials exist. What
190
+ separates them is that a name is words: measured over 147,643 dotted
191
+ identifiers from source on this machine, 0.06% fall below a mean word length
192
+ of 2.5, and the ones that do are JWTs
193
+ - Redact by code point. Slicing by code unit cut a surrogate pair in half and
194
+ wrote a lone surrogate to the terminal
195
+ - Match the placeholder rule's connection-string pattern once rather than three
196
+ times, and bound its scheme: an unbounded `\w+` in front of a literal that is
197
+ usually absent is quadratic in the length of a value someone else writes
198
+ - Make the email rule near-linear on its worst input. The local part
199
+ (`[A-Za-z0-9._%+-]+`) spans the word boundary at every dot, so on a long run
200
+ of digits and separators with no `@` — a log full of IP addresses or version
201
+ numbers is exactly that — every boundary cost a greedy consume of the rest
202
+ of the text plus a character-at-a-time backtrack in search of the `@`:
203
+ O(n²), half a minute for 200 KB, and effectively forever for a multi-MB
204
+ file. The local part is now bounded at 64 characters (RFC 5321's limit, so
205
+ no deliverable address is lost) and the domain is matched as dot-separated
206
+ labels, which leaves nothing to backtrack over
207
+ - Bound the `connection-string` credentials too. `[^@\s]+` crosses both `:` and
208
+ `/`, so a line of `mongodb://` with no `@` in it ran to the end of the text
209
+ from every occurrence: 188 KB took 2.3s, and 1 MiB through the hook took 98s
210
+ and returned exit 0. Every adversarial shape then in the tests walked past it,
211
+ which is the shape list being caught short rather than the guard working, so
212
+ one written for this syntax was added
213
+ - Bound the `env-assignment` pattern's name the same way. It read `[A-Z_]*`
214
+ before its keyword and `[A-Z_0-9]*` after, so a run of capitals with no `=`
215
+ backtracked from every position: 59 KB took 381ms, 234 KB 6.9s, 1 MiB 125s.
216
+ 1 MiB is what the file cap allows through, so capping the read did not stop
217
+ the hook being killed — measured, a 1 MiB file of repeated `SECRET` was still
218
+ killed at 40 seconds with the cap in place. Every rule in the config is now
219
+ run against a list of adversarial shapes in the tests, so this shape fails before
220
+ a release rather than after one
221
+ - Read a file into a buffer of the cap's size rather than of the size `stat`
222
+ reports. procfs and sysfs entries are regular files that report zero bytes and
223
+ produce content anyway, so their content was read as empty and passed.
224
+ `readFileSync`, which this replaced, read to EOF and did not have the problem.
225
+ Reading such a file is not the same as scanning it whole: the NUL rule stops at
226
+ the first separator, so `/proc/self/environ` is read and only its first
227
+ variable is looked at, which is now listed under Known Limitations
228
+ - Scan only the first 1 MiB of a file rather than reading it whole.
229
+ `readFileSync` has no size limit, so a large enough file kept the hook from
230
+ ever returning — and a hook killed by Claude Code's PreToolUse timeout does
231
+ not block the call, which made the hang a way through. A secret past the cut
232
+ is missed, the same trade the transcript's 64 KB tail read already makes
233
+ - Scan the file operand of a pattern-first command when the pattern flag carries
234
+ its value written against it. `grep -eaws secrets`, `grep -faws secrets` and
235
+ `sed -e's/a/b/' secrets` scanned nothing: the attached spelling was not
236
+ recognised, so nothing marked the pattern as supplied and the file that
237
+ followed was consumed as the pattern. The separate (`grep -e aws`) and `=`
238
+ (`--regexp=aws`) spellings were already handled
239
+ - Put back what quieting the rules had taken out. A corpus of five hundred
240
+ generated values, run against this release and against v0.7.0, found a hundred
241
+ and twenty-seven inputs the old version detected and this one did not — none of
242
+ which the thirty-two cases chosen by hand had caught. Restored:
243
+ - an address near an excluded word. One word within a couple of dozen
244
+ characters was erasing every address near it, three at a time in a CSV. The
245
+ exclusion is now the three shapes that are really hostnames: a VCS user, an
246
+ address straight after `ssh`/`scp`/`rsync`/`sftp`, and the `host:path` form
247
+ - a bare private address. Requiring a label lost `192.168.1.50`,
248
+ `X-Forwarded-For: 10.0.0.5` and `remote_addr=…`; what says an address is a
249
+ machine is the command around it, so that is what excludes it now, and a
250
+ `host:port` pair is a service rather than a person
251
+ - an assignment that is not at the start of a line: `docker run -e PASSWORD=…`,
252
+ `cd /app && PASSWORD=…`, a single-quoted value, a value with a trailing
253
+ semicolon or comma, and one indented past sixteen columns. `DB_PASS` counts
254
+ as well as `DB_PASSWORD`
255
+ - a Square token after `key_` or in a query string, which a boundary counting
256
+ `_` and `=` as base64 had erased
257
+ - the Korean resident and business numbers without their separators, which is
258
+ how they are stored. Context keeps a timestamp out instead
259
+ - a postal code next to the word `max`, and a connection string whose password
260
+ runs past 256 characters
261
+ - Read a command that arrives as an argv array on the `Bash` tool too, not only
262
+ on an MCP one. The same command was scanned or not depending on who sent it
263
+ - Block a `.env` template whose contents cannot be read whole. The exemption
264
+ assumed the contents would be scanned instead, and a NUL byte or a file past
265
+ the per-file cut stopped that — so `.env.nul.example` and `.env.big.example`
266
+ passed on their names after all
267
+ - Expand `**` as a single `*` rather than refusing it. Refusing it meant `cat **`
268
+ was scanned not at all, while the shell expanded it and read the files
269
+ - Read a command field that arrives as an argv array or nested under another key.
270
+ Only a top-level string was read, so `{"command":["cat",".env"]}` and
271
+ `{"args":{"command":"cat .env"}}` went past — both by a name with no slash in
272
+ it, which the path rules do not collect either
273
+ - Stop reading after five seconds. A byte budget bounds what is read and not what
274
+ is walked, and a pattern reaching one level under a home directory took ten
275
+ seconds, which is close enough to the PreToolUse timeout to matter
276
+ - Stop blocking ordinary work. Measured over sixty-four commands from a working
277
+ day, the hook blocked sixteen of them; it now blocks five, and four of those
278
+ five are this repository's own README and changelog, which contain an
279
+ AWS-shaped key as documentation. What changed:
280
+ - an address is not a person when an `ssh`, `scp`, `rsync`, `clone` or `git@`
281
+ is next to it, and `example.com` and the other RFC 2606 domains are nobody's
282
+ mail
283
+ - the published test card numbers are not cards
284
+ - `cap` is an English word as well as an Italian postal one, so sizes and
285
+ limits nearby say it is not a postal code
286
+ - the Korean resident and business numbers are written with their separators;
287
+ without that, a millisecond timestamp in a log was a finding
288
+ - a Square token inside a longer run of base64 is a slice of something else,
289
+ which is what made `cat ~/.ssh/known_hosts` a finding
290
+ - a value that is a variable reference (`PASSWORD: ${VAR}`) names a secret
291
+ rather than being one
292
+ - `.env.example`, `.env.sample`, `.env.template`, `.env.dist` and
293
+ `.env.defaults` are not blocked by name. Their contents are still scanned, so
294
+ a template with a real key in it is still caught — by what is in it
295
+ - **`[allow-secret]` lifted PII blocks, which the README says it cannot.**
296
+ Deduplication ran before the allow tag, and it keys on the value — so a string
297
+ that a secret rule and a PII rule both match lost the PII finding first, and
298
+ the tag then removed what was left. The two hooks had the order the other way
299
+ round from each other; the prompt hook was right
300
+ - **A pasted log could lift the guard on the key in the same message.** The
301
+ prompt hook read tags from the raw prompt while the other hook read them from
302
+ what the user typed, so a fenced log or a README quoting `[allow-secret]`
303
+ decided them. One implementation now answers for both
304
+ - **Input the check could not read was treated as input the check approved.**
305
+ Two characters missing from the end of a payload passed a key through. Empty
306
+ stdin is still nothing to check; bytes that will not parse now stop the call
307
+ - **A filename could put lines into the text Claude reads.** POSIX allows a
308
+ newline in a path and a path is attacker-chosen, so a file could be named such
309
+ that the block message grew a line saying the block was a false positive.
310
+ Escape sequences went the same way and could clear the screen first. Control
311
+ characters are escaped on the way out now, and the finding list is capped —
312
+ one rule that matched everywhere produced forty thousand lines
313
+ - **A single rule from a config file could hang the hook.** The scan budget is
314
+ checked between rules and cannot interrupt one match, so `(a+)+$` ran for
315
+ hours and the hook was killed — which does not block. A V8-side timeout does
316
+ interrupt a running match, at 0.06ms per scan
317
+ - A config path that is a FIFO blocked the read forever, and a config with more
318
+ than about 120,000 rules threw while the module was still loading, before any
319
+ handler existed. Both exited without blocking
320
+ - **`tail` printed the part that was not scanned.** The per-file cap reads the
321
+ first megabyte; `tail -2 app.log` shows the last lines, which on a large log is
322
+ where a failure has just printed a connection string. Both ends are read now.
323
+ What is still missed is the middle of a file larger than both windows
324
+ - `view` and `vimdiff` print a file the way `less` does and were not on the list
325
+ - The documentation said the hooks are active immediately after installing the
326
+ plugin. A session that is already running does not pick them up: it reports the
327
+ plugin as enabled and checks nothing. It also said a PreToolUse allow tag is
328
+ consumed by the first tool call — it lasts until a tool result is recorded, so
329
+ calls issued together are all covered by one. And it said a `.env` with an
330
+ allow tag is passed through without scanning, which is the opposite of what the
331
+ code does. All three are corrected, and the step that proves a hook is really
332
+ running is now on the recommended install path rather than only the pnpm one
333
+ - The `phone-jp` validator existed and was named in neither document; a test now
334
+ holds both documents to the registry. Added a section on the ways a rule goes
335
+ quiet without warning — `secretGroup: 0` is not the same as omitting it, an
336
+ `entropyThreshold` above 8 rejects everything, `flags: "y"` matches only at the
337
+ start of the text, and a large `contextWindow` widens `excludeContext` too
338
+ - **The same defect was still in `env-assignment`, and worse.** Its value
339
+ capture was open-ended, so a megabyte of `TOKEN=TOKEN=…` took six minutes —
340
+ past any hook timeout, and a killed hook does not block. The capture is atomic
341
+ now (`(?=(X))\1`, since every character the delimiter test accepts is one the
342
+ class already excludes, so retrying a shorter run could never succeed) and
343
+ capped, with a single character deciding whether the value simply ran past the
344
+ cap. 373 seconds to 2 milliseconds, and a fifty-thousand-character value is
345
+ still found
346
+ - **The hook stopped every tool call, with no way out, when its working
347
+ directory had been removed.** `process.cwd()` throws there, and it was called
348
+ while the module was still loading — before the transcript is read — so the
349
+ message advising an allow tag described something that could not be honoured.
350
+ A build script that runs `rm -rf dist` from inside `dist`, or a
351
+ `git worktree remove`, is enough. There is nothing sensitive about a missing
352
+ directory: a relative path simply has no base
353
+ - **A tag written in backticks did not work, and the documentation writes them
354
+ that way.** Treating inline code as quoting refused the form this project
355
+ teaches, and refused it silently — the block that followed advised adding the
356
+ tag it had just ignored. Fenced blocks still quote rather than issue, so a
357
+ pasted log cannot lift the guard
358
+ - `<bash-input>` was missing from the elements that are not user input, an
359
+ unclosed element was not stripped at all, and a line the runtime wrote as an
360
+ assistant turn was read as user input if the message inside it claimed the
361
+ role
362
+ - A UTF-16 file whose first characters are Japanese or Chinese has no zero byte
363
+ among them, and five hundred pairs of prefix decided the whole file. The
364
+ window is wider, the threshold is on the asymmetry rather than the rate, and
365
+ whether the result reads as text is what settles it
366
+ - A FIFO named as the transcript blocked the read forever; a write to a closed
367
+ stderr threw on the way out of a block and turned it into a pass; and a
368
+ payload of `null` parsed successfully and then threw on the first field read
369
+ - **Twenty-six wrong blocks out of six hundred real files.** A value that is a
370
+ URL, a path, an identifier, a header name, a number or a dotted setting name
371
+ is no longer read as the secret its variable is named after — `secret_name`,
372
+ `VAULT_TOKEN_PATH` and `TOKEN_HEADER_NAME` describe a secret rather than
373
+ holding one, and a key whose last word is `PROJECT` or `ENDPOINT` says so
374
+ outright. The shape test applies only where a rule captured a free-form value:
375
+ a Slack webhook is a URL and a secret both, and asking whether it looks like a
376
+ URL is the wrong question
377
+ - A connection string with `${PGPASSWORD}` still in it holds no credential at
378
+ all, and `postgres:postgres@` is what a compose file ships with
379
+ - A context word is a label, not a fragment of the identifier beside the number.
380
+ `extract-zip` supplied "zip" and `golang.org/x/mobile` supplied "mobile", so a
381
+ version number beside either read as a postal code or a telephone number —
382
+ which is to say lockfiles and `go.sum` could not be read at all. Nor could
383
+ `name@version`, which is an address by shape
384
+ - Twelve identical digits satisfy the My Number checksum by arithmetic rather
385
+ than by being anyone's number, and `01-02-2024` is a date. A Japanese
386
+ telephone number has ten digits or eleven, and 0120 belongs to a business
387
+ - **A megabyte of `eyJ` used to kill the hook, and a killed hook does not
388
+ block.** Two rules were shaped `{n,}` followed by a literal that may never
389
+ come, which makes the engine retry the whole tail from every start position.
390
+ `eyJ` recurs every three characters, so one 400 KiB file was enough to spend
391
+ the PreToolUse timeout and take the rest of the call with it — including the
392
+ `.env` name guard, by naming the padding first. A JWT begins at a token
393
+ boundary, and saying so leaves one start instead of a third of a million: a
394
+ megabyte went from 104 seconds to 27 milliseconds. The Mapbox, Sentry and
395
+ Square patterns had the same shape and are bounded too
396
+ - A scan that runs past ten seconds now stops the call rather than finishing
397
+ quietly. Bounding those patterns fixed the two rules that could do it; this is
398
+ so the next rule of that shape is caught instead of repeating it. The check
399
+ sits between rules, since a single match cannot be interrupted
400
+ - **An allow tag could be issued by something other than the user.** Claude Code
401
+ records the output of a `!` command, slash-command names and system reminders
402
+ as user messages, so `[allow-all]` appearing in any of them lifted the guard
403
+ for the next tool call — `grep -r allow-all` was enough. A tag inside a code
404
+ fence no longer counts either: a pasted log is quoting the tag, not asking for
405
+ it
406
+ - **A UTF-16 file was not scanned at all.** Every other byte is NUL, and the
407
+ scan stops at the first one, so the contents came to one character. PowerShell
408
+ 5.1 writes UTF-16LE by default, which makes redirecting a command's output to
409
+ a file a way past this. Little-endian, big-endian and byte-order-marked files
410
+ are all read now; genuinely binary files are still left alone
411
+ - `Read` with a `file_path` that is not a string exited 0, while the same shape
412
+ under any other tool name reached the shared collector and blocked
413
+ - **A crash no longer passes the call through.** Only exit 2 blocks, and an
414
+ unforeseen error exits 1 — so any bug anywhere in a hook silently switched the
415
+ protection off, which is the failure this tool exists to prevent. Both hooks
416
+ now stop the call instead, with a message saying the check did not finish
417
+ rather than claiming a finding. Input the hooks do understand is unaffected;
418
+ `[allow-all]` gets past it
419
+ - **A prompt that is not a string is read rather than dropped.** Not throwing on
420
+ `{"prompt":{"text":"…"}}` was only half the fix: coercing it to the empty
421
+ string exited 0, which is the same silence the exception produced. Every
422
+ string inside the value is collected now, to a bounded depth, so object,
423
+ array and content-block prompts are scanned like a plain one
424
+ - A field named `command.line` or `command line` was walked past while
425
+ `file.path` was read correctly — the two collectors normalised field names
426
+ with a regex each, and the one for commands dropped only `-` and `_`
427
+ - The placeholder recognition added above could be used to smuggle a live
428
+ credential: it asked whether a value *contained* a placeholder word, so
429
+ `changeme_` in front of a real key switched the rule off. The whole value has
430
+ to be placeholder now
431
+ - Widening the Stripe and OpenAI rules swallowed two rules whole:
432
+ `stripe-restricted-key` became a strict subset of `stripe-secret-key`, and
433
+ `openai-project-key` stopped being reported at all. Both fire again
434
+ - **Private IPv4 addresses are no longer detected.** An RFC 1918 address is
435
+ non-routable and identifies nothing outside the network it belongs to, and the
436
+ rule spent its time on ansible inventories, ssh configs, Kubernetes manifests
437
+ and docker-compose files — five such files, all blocked before, all quiet now.
438
+ Public addresses are unchanged and still require a nearby label. Anyone who
439
+ wants the old behaviour can add the rule back through the config file; the
440
+ `excludeContext` field it used is documented now and still serves the postal
441
+ code rule
442
+ - The release could not publish. GitHub runs every `run:` step as `bash -e {0}`,
443
+ and the smoke test added last round pipes into a hook that exits 2 on purpose,
444
+ so errexit killed the step before the assertion that expected the 2. The gate
445
+ written to make the release safer made it impossible; every invocation now
446
+ captures its status instead of letting the pipeline decide the step's fate
447
+ - The release gates now run against the tarball `npm publish` would upload, not
448
+ the checked-out tree. Deleting `"dist/"` from the `files` field used to pass
449
+ every gate while shipping a package whose hooks cannot start — verified by
450
+ doing it, along with dropping `hooks/` and shipping the tests
451
+ - `hooks/hooks.json` — the file the plugin install path reads, and the only one
452
+ still pointing at the TypeScript sources — had no gate at all. Emptying it left
453
+ every check green. The release now parses it, requires both events, and
454
+ resolves every command's path inside the tarball
455
+ - Recognise a value written to be replaced. Half of a realistic `.env.example`
456
+ was blocked on its contents (`your-password-here`, `REPLACE_ME_WITH_REAL`,
457
+ `django-insecure-...`, `postgres://user:password@localhost/db`), which defeats
458
+ exempting the name: the file exists to be committed and read. Ten realistic
459
+ templates now read clean, and one holding a live key is still blocked. Only
460
+ secret rules consult the list, and `example` is deliberately not on it — AWS's
461
+ own documented key ends in it and is still a key
462
+ - An address stopped being found when a remote-shell word appeared anywhere
463
+ within forty characters: `rsync failed, notify alice@corp.io` was silently
464
+ dropped. The exemption now covers the operand position only — `ssh user@host`
465
+ and at most two arguments in between — and the `host:path` forms of scp and
466
+ rsync are left to the trailing-colon rule that already handled them
467
+ - Cover the credit card brands the rule claimed and did not match. The Discover
468
+ branch required seventeen digits, so no Discover, JCB or Diners card could
469
+ reach it, and Mastercard's 2-series (2221-2720) and UnionPay were absent
470
+ outright — five brands undetected. Ranges follow Discover's published IIN
471
+ summary, which also puts the Discover range at 644-658, so 659 is no longer
472
+ claimed
473
+ - Slack's rotated tokens (`xoxe-`), app-level tokens (`xapp-`) and workflow
474
+ tokens (`xwfp-`) were not matched; nor were Stripe restricted, organization
475
+ and webhook-signing secrets, nor eight of GitLab's ten token prefixes
476
+ - Mapbox and Sentry tokens were written to shapes those services do not issue —
477
+ Mapbox delimits into three parts of which the first is the literal `pk`, `sk`
478
+ or `tk`, and a Sentry org token is underscore-separated, not dotted. Neither
479
+ rule had ever matched a real token
480
+ - A Square token longer than sixty characters was missed. Square's contract
481
+ allows up to 1024; the length had been pinned at exactly what appears in the
482
+ wild
483
+ - Codice fiscale: omocodia substitutes letters for digits at the seven numeric
484
+ positions when two people would share the first fifteen characters, and both
485
+ the pattern and the checksum guard demanded digits there — so every such code,
486
+ each issued to a real person, was missed
487
+ - Add two more from a format survey: an Azure Shared Access Key
488
+ (`SharedAccessKey=` + 44-char base64, for Service Bus, Event Hubs and IoT Hub,
489
+ which is a different length from the 88-character storage account key) and a
490
+ Google OAuth client secret (`GOCSPX-`). Google's `ya29.` access tokens and
491
+ `1//` refresh tokens are deliberately not matched: Google documents no format
492
+ for them beyond a size cap and reserves the right to change it, so a pattern
493
+ would be a guess that reads as a guarantee
494
+ - Add nine rules for credentials that no rule covered: OpenAI service-account and
495
+ admin keys, Azure Storage account keys, Fly.io, Databricks, HashiCorp Vault,
496
+ Shopify, Doppler, Grafana and Notion tokens. 64 rules to 73
497
+ - Stop repeating the blocked command back to Claude. The reason a block gives
498
+ carried the first eighty characters of the command, so blocking
499
+ `export GITHUB_TOKEN=ghp_…` handed the token to the model inside the sentence
500
+ explaining that it had been withheld. The detection lines were already
501
+ redacted; the line above them was not
502
+ - Read a command out of a tool input field. Only `Bash` was ever parsed as a
503
+ command, so an MCP server that runs a shell — `{"command":"cat .env"}` — was
504
+ looked at as a path, found not to be a file, and let through, with the default
505
+ matcher sending every `mcp__*` tool down that path. `command`, `cmd`, `script`
506
+ and `code` are read now, the last for the paths quoted inside it
507
+ - Treat an input of the wrong type as absent rather than throwing. A `command`
508
+ that is a number, a `prompt` that is an object, a `cwd` that is an array: each
509
+ threw, and an exception exits 1, which does not block —
510
+ `{"prompt":{"text":"<a key>"}}` went through unscanned
511
+ - Anchor the assignment rule to the start of a line and require its value to be a
512
+ value. Widening it to `:` and lower case made it read ordinary code:
513
+ `function check(token: ShellToken)` was a secret, and the plugin could not read
514
+ its own source — 97 findings across 17 files of this repository, now none
515
+ - Resolve a relative path against the directory the tool runs in. The payload
516
+ carries a `cwd` and nothing read it, so `cat secrets.txt` named a path relative
517
+ to wherever the hook process happened to start and was dropped as a file that
518
+ is not there. A literal `cd` earlier in the same command moves the base too,
519
+ which is what `cd build && cat secrets` needs
520
+ - Read an assignment written with `:` and with a lower-case name. The rule wanted
521
+ `[A-Z_]` and `=`, so a `docker-compose.yml` full of `POSTGRES_PASSWORD: …`, an
522
+ `appsettings.json` with `"client_secret": …` and an `~/.aws/credentials` with
523
+ `aws_secret_access_key = …` all passed — the three file shapes this tool exists
524
+ to guard
525
+ - Keep a substitution among the operands instead of ending the segment at it.
526
+ `cat <(echo hi) secrets` left `secrets` in a segment of its own, where it was
527
+ read as a command name; the comment at that line said the only cost was
528
+ reaching the inner command twice
529
+ - Bound the work of one tool call at 64 MiB across every file it reads, and skip a
530
+ file already read. A glob naming three hundred large files took half a minute,
531
+ and five overlapping globs read the same files five times
532
+ - Lift the `.env` name block only for a tag that allows secrets. `parseAllowTags`
533
+ reads `[allow-<anything>]`, and the guard asked only whether any tag was
534
+ present, so `[allow-pii]` and a mistyped `[allow-pi]` both turned it off. It no
535
+ longer skips the content scan either: a tag for one category was silently
536
+ covering the other
537
+ - Expand `~` and `~/…` to the home directory. `cat ~/.aws/credentials` named a
538
+ path that exists on no disk, so it was dropped as a file that is not there —
539
+ and `~/.ssh/id_rsa`, `~/.npmrc` and `~/.netrc` went the same way
540
+ - Expand `{a,b}` as well as `*`, `?` and `[`. `cat .env{,.bak}` reached the name
541
+ guard as the single name `.env{`, which is not an `.env` file, so the guard
542
+ that reads names rather than disks did not fire
543
+ - Keep the literal candidate beside a glob's matches. Returning only the matches
544
+ was a way through this hook did not have before the expansion existed:
545
+ `cat /nonexistent/.env.*` matches nothing, so nothing was scanned and the
546
+ `.env` name guard never ran, and a file really named `report[2].txt` was read
547
+ as a character class and expanded to `report2.txt`
548
+ - Read a shell's bundled `-c`. `bash -lc 'cat secrets'` runs what `bash -c`
549
+ runs, and only the exact spelling was recognised, so the inline code went
550
+ unparsed. The letters before the `c` have to be valueless switches
551
+ - Step past `eval` the way the other wrappers are stepped past
552
+ - Read `$(<secrets)`, which has no command in it at all: bash reads the file and
553
+ substitutes its contents, so the redirection is the only thing there
554
+ - Scan the quoted literals inside an awk or sed program.
555
+ `awk 'BEGIN{while((getline l < "secrets")>0) print l}'` names a file without
556
+ ever passing it as an operand
557
+ - Detect `-----BEGIN ENCRYPTED PRIVATE KEY-----` and the SSH2 spelling, which
558
+ `openssl genpkey -aes256` writes and the rule did not list
559
+ - Expand a glob before deciding whether it names a file. `cat sec*` collected
560
+ `sec*`, found nothing on disk by that name, and allowed the read; `cat .env*`
561
+ did the same, one character away from `cat .env`, which is blocked on its name.
562
+ A pattern is now expanded and each match is scanned, up to 256 of them
563
+ - Read a redirection that stands before the command. `< secrets cat` is `cat`
564
+ reading `secrets`, but the operator was skipped and its target taken for the
565
+ command name, so the real command went unclassified and nothing of it was
566
+ collected — while `cat < secrets` blocked
567
+ - Scan the file named inside a `git log -L` range. `-L1,10:secrets` prints the
568
+ lines of that file, and the file is written inside the flag's own argument
569
+ where neither the flag nor the operand handling would look for it
570
+ - Read `--` as the end of option parsing for the in-place test too. In
571
+ `sed -- -i secrets`, `-i` is the script and `secrets` is a file sed prints; read
572
+ as the in-place flag, the command counted as writing and the file was skipped
573
+ - Collect a path from an array inside an array. `{ "paths": [["…"]] }` fell
574
+ between the string branch and the object branch and was never looked at
575
+ - Stop reading a path that names something other than a regular file. Reading
576
+ `/dev/zero` never reaches the end of the file, so the hook did not return and
577
+ Claude Code's PreToolUse timeout killed it — and a killed hook does not block
578
+ the call, which made the hang a way through. The tool-input side already
579
+ stat'd first; the Bash side now does too. On the paths that name a file
580
+ outright — `Read` and a Bash command — `.env` and `.env.*` are still blocked
581
+ on the name alone, before anything is opened. A tool input naming no existing
582
+ file is left alone as before, since its "path" may be a URL route or an object
583
+ key. What is no longer read is a FIFO, a process substitution or `/dev/stdin`,
584
+ which is now listed under Known Limitations
585
+ - Read `--` as the end of option parsing. `grep -- -aws secrets` searches for
586
+ `-aws` in `secrets`, but `-aws` was taken for a flag, so nothing marked the
587
+ pattern as supplied and `secrets` was consumed in its place rather than
588
+ scanned. Without the `--` the same tokens mean what they did before: the file
589
+ is the pattern and the command reads stdin
590
+ - Scan a variable named inside another expansion's suffix. `${A:-$TOKEN}` prints
591
+ `$TOKEN` whenever `A` is unset, but each expansion was matched whole, so the
592
+ skip to the closing brace swallowed the suffix and the name in it. Every `$` a
593
+ name follows now counts, which also takes in an unclosed `${TOKEN`: searching
594
+ a checkout for template references with `grep -rn '${TOKEN' .` is blocked when
595
+ that variable holds a secret. A false block, and the same direction the hook
596
+ already errs in for `echo '$TOKEN'`
597
+ - `.claude-plugin/plugin.json` declared `0.5.1` while `package.json` declared
598
+ `0.7.0`: the release checklist asks for both, and the bump was missed for
599
+ 0.6.0 and 0.7.0. The plugin manifest now matches the released version
600
+ - Also treat the rest of the digest commands as measuring a file rather than
601
+ printing it: `sha512sum < secrets` was scanned while `sha256sum < secrets` was
602
+ not, because only four of the family were listed. `sha224sum`, `sha384sum`,
603
+ `sha512sum`, `b2sum`, `shasum`, `md5` and `sum` join them
604
+ - Read a directory when a tool is pointed at one. `grep -r AKIA .` and a Grep
605
+ whose `path` names a folder both return file contents, and both reached a
606
+ check that asks whether the candidate is a regular file, found it is not, and
607
+ let the call through — with the key printed to stdout. The files directly
608
+ inside are now scanned, one level deep and capped at the same limit a glob is.
609
+ Measured over forty-five real directories, five block, and all five hold
610
+ credential-shaped assignments
611
+ - Scan what a command says, not only what it opens, for every tool rather than
612
+ only `Bash`. An MCP server that runs a shell takes `{"command":"echo AKIA…"}`,
613
+ and the key was in the argument list unread
614
+ - Scan every run of text in a file that holds NUL bytes. Scanning stopped at the
615
+ first one, so a single leading NUL reduced the scan to the empty string — and
616
+ an empty scan finds nothing and allows the read. The runs are joined by
617
+ newlines so no rule matches across two of them
618
+ - Recognise UTF-16 when one side of each byte pair dominates, rather than when
619
+ the other side is empty. Characters in the U+xx00 rows — U+3000, the
620
+ ideographic space, among them — put a single NUL on the wrong side, and one of
621
+ those in a Japanese document sent the whole file down the binary path
622
+ - Expand `$VAR` and `${VAR}` in a path. `cat ~/.aws/credentials` was blocked and
623
+ `cat $HOME/.aws/credentials` was not, so the guard turned on which of two
624
+ spellings the author used. A variable that is unset is left as written
625
+ - Hold the scan budget across the whole hook invocation rather than resetting it
626
+ per `scan()` call. A hook scans once per environment variable and twice per
627
+ file, so each call stayed inside the budget while the total did not: with a
628
+ slow rule in a user config and sixty variables, measured at 29 seconds and
629
+ exit 0. Now capped at 10.5 seconds, and past the budget it exits 2
630
+ - Fifteen detection rules matched a shape the vendor does not issue. `flyio-token`
631
+ required the `FlyV1 ` auth scheme, which is not part of the token, and excluded
632
+ `_` and `-` from a base64url body; `linear-key`, `twilio-sid` and `postman-key`
633
+ were lower-case only against mixed-case and hex formats; `notion-token`,
634
+ `digitalocean-pat`, `gitlab-pat`, `square-access-token` and `huggingface-token`
635
+ each covered one of the prefixes their vendor issues; `replicate-token` omitted
636
+ the hyphen; `azure-sas-key` fixed the length at 43 where Azure IoT DPS
637
+ documents 16–64 byte keys; `anthropic-key` asked for 95 characters after the
638
+ prefix where the format has 101, truncating the match; and `twilio-sid` had no
639
+ rule for the API Key SID at all
640
+ - Start the scan clock when the payload arrives, not when the process does.
641
+ Both the five-second file deadline and the scan budget began at module load
642
+ and counted the wait for stdin against themselves, so a slow handover spent
643
+ the whole allowance before a file was read: six seconds of delay and nothing
644
+ was scanned, on an exit code of 0
645
+ - Stop a compaction summary and a skill body from carrying an allow tag. Both
646
+ are written by the runtime with the user's role, and neither is anyone asking
647
+ for anything: a summary re-injects earlier turns, so a tag discussed at any
648
+ point in a conversation came back armed, and a meta line carries file content,
649
+ so writing a `SKILL.md` was enough to lift every check
650
+ - Treat a fence that never closes as quoting, the way an unclosed synthetic
651
+ element already was. A paste cut short is still a paste
652
+ - Return a quarter of a value in the block reason rather than eight characters
653
+ of it. The reason is written to stderr, which is where Claude reads it, so it
654
+ reaches the API the block exists to keep the value from — of a nine-character
655
+ password, eight characters were being handed back
656
+ - Skip binaries when a directory is swept, while still scanning one in full when
657
+ it is named outright. Nobody asked for the files a directory sweep picks up,
658
+ and a folder of images cost three seconds and reported the compressed bytes as
659
+ email addresses. Measured: 8 MiB of images, 3,103ms to 131ms
660
+ - Require a NUL imbalance to be near-total before reading a file as UTF-16. An
661
+ eight-to-one ratio read four real binaries out of seventeen thousand as text,
662
+ and a key sitting in a JPEG's bytes went unfound because the file decoded to
663
+ nonsense
664
+ - Stop reading a reference to a value in code as the value. `env-assignment`
665
+ matched `process.env.API_TOKEN`, `user.password_digest` and `self.api_key`;
666
+ two in five of the distinct values it matched across thirty thousand real
667
+ files were one of these
668
+ - The last tag in a message is the one that applies, replacing the earlier ones
669
+ rather than merging with them. Resolving each category separately kept the
670
+ wider grant of the two, so `[allow-all]` narrowed to `[allow-secret]` went on
671
+ allowing PII — the opposite of what narrowing means. Two tags no longer add
672
+ up: `[allow-all]` is how both categories are asked for
673
+ - Resolve tags the same way in both hooks. `PreToolUse` collected every allow
674
+ tag and never looked at mask tags, so `[mask-secret] [allow-secret]` stopped
675
+ the prompt and then allowed the tool call it was stopping
676
+ - Read AWS's documented keys as documentation. AWS writes `EXAMPLE` where the
677
+ random part would end — `AKIAIOSFODNN7EXAMPLE` and its siblings — and those
678
+ appear in every setup guide and in every README that copies one, where a block
679
+ reads exactly like a block on a live key. The new `aws-key` validator rejects
680
+ the suffix; a real key whose last seven characters spell it is one in
681
+ thirty-six to the seventh. This project's own README, installation page and
682
+ getting-started page were among the files it made unreadable
683
+ - Stop reading a Retina asset filename as an email address. `logo@2x.png`
684
+ satisfied the pattern because `png` is two or more letters. Thirty asset
685
+ extensions are excluded, none of which is a country code
686
+ - Match card numbers at every length their brand issues. ISO/IEC 7812 allows
687
+ 10–19 digits and the pattern encoded one length per brand, so 19-digit
688
+ UnionPay, JCB and Discover cards, 13-digit Visa, and every Maestro card went
689
+ unmatched. Swept over 986 real files, the wider pattern adds no false positive
690
+
691
+ ### Documentation
692
+
693
+ - `SECURITY.md` described the parse-error path as a fail-open that exits 0. It
694
+ exits 2 — the security policy stated the protection backwards
695
+ - `docs/troubleshooting.md` said hooks activate without a restart, which the
696
+ README, the installation page and the getting-started page all contradict.
697
+ Restarting is now the first step, since a session that missed the hooks lists
698
+ the plugin as enabled and checks nothing
699
+ - `README.md` said only the first 1 MiB of a file is scanned. Both ends are
700
+ read; what is missed is the middle
701
+ - `docs/rules.md` said any allow tag lifts the `.env` name block and passes the
702
+ file through without scanning. `[allow-pii]` does not lift it, and the
703
+ contents are scanned either way
704
+ - `docs/rules.md` gave `API_KEY=placeholder` as a value the entropy threshold
705
+ reports. It is not reported — the placeholder test drops it
706
+ - The file structure in `README.md` listed two files under `src/lib/`, from
707
+ before this release added three more and moved the rules into JSON
708
+ - The development commands in `README.md` were `npm`, while `CONTRIBUTING.md`
709
+ and every CI job are `pnpm`. The lockfile is pnpm's, so following the README
710
+ ignored it, wrote a second lockfile, and resolved a different tree from the one
711
+ that is tested
712
+
713
+ ### CI
714
+
715
+ - Publish compiled JavaScript. Node refuses to strip types from a `.ts` file
716
+ inside `node_modules`, so an npm install wired to `src/` started the hook,
717
+ failed with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, exited 1 — and a
718
+ non-zero exit that is not 2 does not block. The tool looked installed and
719
+ checked nothing. `dist/` now ships beside `src/`, the npm instructions point at
720
+ it, and no type stripper or `tsx` download is involved. The plugin install
721
+ keeps using the sources, which sit outside `node_modules` and work
722
+ - Run the release path's own gates. `release.yml` is reached by a push to `main`
723
+ and is not chained to the pull-request build, so a red branch could publish. It
724
+ now runs the audit, the version-agreement check whose absence let the plugin
725
+ manifest ship stale twice, a build, and a smoke test that starts both published
726
+ hooks with plain `node`
727
+ - Stop shipping the tests. `files` carried `src/`, which carried `__tests__`:
728
+ more than half the tarball, and none of it useful to anyone installing
729
+
730
+ - Add a `versions` job that fails when `package.json` and
731
+ `.claude-plugin/plugin.json` declare different versions, or when either
732
+ declares nothing that looks like one
733
+ - Check `vitest.config.ts` the way `src/` is checked. It sits at the repository
734
+ root, and both `tsc` and `biome` were scoped to `src`, so the file that decides
735
+ how the tests run was neither typechecked nor linted
736
+ - Run CI on pushes to `main` and `develop`, not only on pull requests. The
737
+ commit a merge makes belongs to no PR, so nothing built it: two branches that
738
+ are green apart can still be red together
739
+ - The release could not publish, again, and for a new reason. The smoke test now
740
+ starts each hook through `eval`, and under the `bash -e {0}` GitHub runs every
741
+ step with, errexit fires inside the pipeline's subshell: a hook exiting 2 on
742
+ purpose reached the assertion as 1, so all eight blocking assertions failed.
743
+ `set -uo pipefail` does not clear `-e`, which arrives from the invocation.
744
+ Measured: without `set +e`, eight errors and the step exits 1
745
+ - Make every step after the publish recoverable. The job was gated on
746
+ `should_release`, which npm alone decides, so a failure after the publish left
747
+ the version on npm with no GitHub Release and no catalog sync, and a re-run
748
+ skipped the job entirely. Only the publish is conditional now; the tag, the
749
+ release and the sync are idempotent and run every time
750
+ - Run the release smoke test against the commands `hooks/hooks.json` declares,
751
+ as well as against `dist/`. The manifest starts `src/*.ts` under type
752
+ stripping, which is what a plugin install runs and what the gate only checked
753
+ the existence of; `dist/*.js` is what the npm instructions point at. The
754
+ commands are read back from the manifest so the two cannot drift apart
755
+ - Fail the release when the marketplace catalog does not pin this plugin to
756
+ `main`. `/plugin install` serves the entry's `ref`, and with no `ref` that is
757
+ the repository's default branch — `develop`. Every gate in `release.yml`
758
+ guards `main` and npm, and none of them was on the path a plugin user installs
759
+ from, so a merge into `develop` reached users directly
760
+ - Anchor the version shape test at both ends, in `ci.yml` and `release.yml`
761
+ alike. `^[0-9]+\.[0-9]+\.[0-9]+` with no `$` accepts anything at all after a
762
+ valid prefix, and `release.yml` splices that value into four `run:` scripts:
763
+ `0.8.0"; curl … | sh; echo "`, `0.8.0 && rm -rf /` and `0.8.0$(id)` were all
764
+ accepted by the old test and are all rejected by the new one. The version is
765
+ now validated in the job that captures it, before it reaches `$GITHUB_OUTPUT`,
766
+ and every step that uses it reads it from `env:` rather than by interpolation
767
+ - Create the git tag before publishing to npm, and let npm alone decide whether
768
+ a version is released. npm refuses to republish, so a publish that landed and
769
+ was followed by a failing step could not be retried — the tag it never created
770
+ had to be made by hand, and the release job declined to act on a re-run
771
+ - Give `release.yml` a `concurrency` group, so two pushes to `main` in quick
772
+ succession cannot race over the tag and the publish. Nothing is cancelled: a
773
+ release half-way through is worse than one that waits
774
+ - Put a `timeout-minutes` on every job in both workflows. A hung step otherwise
775
+ holds a runner for the six-hour default
776
+ - Assert that the published `UserPromptSubmit` hook allows a clean prompt. Every
777
+ assertion made of it was that it exits 2, so a hook that exits 2
778
+ unconditionally — blocking every prompt the user types — would have shipped
779
+ green
780
+ - Install with `--frozen-lockfile` in CI. A lockfile CI is allowed to rewrite is
781
+ a lockfile CI does not check
782
+ - Take CodeQL off GitHub's default setup and run it from `codeql.yml`. Default
783
+ setup only analyses a pull request whose base is the default or a protected
784
+ branch, so a PR stacked on another feature branch was never scanned — the same
785
+ gap `ci.yml` had, and the branches it covers cannot be configured. The new
786
+ workflow analyses pull requests, pushes to `main` and `develop`, and a weekly
787
+ schedule, because an advisory lands after a change merges rather than only
788
+ alongside one. The two setups cannot coexist: default setup has to be off for
789
+ these analyses to be accepted
790
+ - Fail the lint on warnings, and check `docs/.vitepress/` the way `src/` is
791
+ checked. `biome lint` exits 0 on a warning, so the dead `tokenize` in
792
+ `src/lib/rules.ts` — superseded by `contextTokens`, and carrying a comment
793
+ describing the behaviour that replaced it — sat in the tree reported and
794
+ ignored. Two rules are turned off rather than obeyed: `useLiteralKeys`
795
+ contradicts this project's `noPropertyAccessFromIndexSignature`, and applying
796
+ it broke the typecheck; `noTemplateCurlyInString` is off for the test tree,
797
+ which cannot test `${VAR}` handling without writing one
798
+
799
+ ---
800
+
3
801
  ## v0.7.0 (2026-08-04)
4
802
 
5
803
  ### Features