Changelog
Unreleased
Documentation
- The README's feature list and the docs site's landing page name the date of birth, member number and postal address rules. Only the rule tables listed them before.
v0.16.0 (2026-10-24)
Features
- Dates of birth and member, customer and account numbers after a label are found in the six other languages pii-sentinel covers, one rule per language and kind:
pii-date-of-birth-{zh,ko,fr,it,de,es}andpii-customer-number-{zh,ko,fr,it,de,es}. Each rule carries its own language's labels (Geburtsdatum,Geb.-Datum,geboren am;date de naissance,né(e) le;data di nascita,nato/a il;fecha de nacimiento,nacido/a el;出生日期,出生年月日;생년월일,출생일;Kundennummer,Kd.-Nr.,Mitgliedsnummer,Kontonummer;numéro de client,N° d'adhérent,numéro de compte;numero cliente,codice cliente,numero socio;número de cliente,n.º de socio,número de cuenta;客户编号,会员号,会员卡号,银行账号;고객번호,회원번호,계좌번호), its month names with and without accents (12. März 1985,1er mars 1985,1º marzo 1985,12 de marzo de 1985), and the numeric forms, including1985年3月12日,1985년 3월 12일and the Korean1985. 3. 12.. The existing English and Japanese rules are unchanged. A label that names an event or a login account as often as a person's attribute (Geburtstag,生日,생일,账号,Konto-ID,ID de compte) is not taken, and a label that is the tail of a longer key (billing_kundennummer) is not a label, as in English. English month names after these labels and month-first forms (März 12, 1985) are not read, since each rule reads its own language and these languages write the day first. Thebirth-datevalidator reads the new forms; two-digit years are still not read. A member number of nothing but zeros (00000000) is a form's placeholder, and the six member-number rules do not read it. Each rule reads a table column under its labels, as the English rules do (#338) - A postal address in China, Korea, France, Italy, Germany or Spain is found by its shape, one rule per country:
pii-address-cn,pii-address-kr,pii-address-fr,pii-address-it,pii-address-deandpii-address-es, beside the Japanesepii-address-jp. The four European rules need a street type and name, a house number, and within a short distance a five-digit postal code and a capitalised town:12 rue de la Paix, 75002 Paris,Via Roma 12, 00100 Roma,Hauptstraße 12, 10115 Berlin,C/ Florida, 19, 3.º izda., 01008 Vitoria-Gasteiz. A street without a number, a town alone, a postal code alone, a price after a street (coûte 12000 euros) and a street word in code (class Strasse) are not found; Between the house number and the code only separators and real address tokens are read (int. 5,scala B,3.º izda.,Hinterhaus,Bât. C,BP 123,PLZ:), so a free word ends the match anddeployed via Jenkins 12 times, 10000 Buildsis not an address, and a currency or unit word is not a town (Am Ende 3, 10000 Euro Berlin). The German rule also needs a comma, a semicolon, a pipe, a dash, a newline or two spaces before the code, sinceMusterweg 3 kostet 12000 Eurois a sentence. An address without a postal code, or with the town before the code, is not found, and a capitalised noun after a code in range can still be read as a town (Hauptstraße 12, 10000 Visitors Berlin). The Chinese rule reads a province, or a 市, 区, 县, 镇, 乡 or 街道 unit, a road (路, 街, 道, 胡同, 巷, 弄, with 段 sections) and a number with 号 (北京市海淀区中关村大街1号,上海市虹口区西康南路125弄34号201室), and refuses a metro line (3号线), a date (3月12号) and a building with no road (1号楼). The Korean rule reads a 시/도 or 구/군 unit, then a road name ending in 로 or 길 with a building number (서울특별시 강남구 테헤란로 123) or a 동/리 with a lot number (서울특별시 서초구 서초동 1540-5), and refuses the particle 로 (서울로 갑니다) and counters (123명,2번 출구). A company's address is found as a person's is (#338)
v0.15.0 (2026-10-20)
Breaking changes
"blockOn": "identifiable"blocked files thathighdid not, on the probabilities of nine categories, each at a placeholder threshold of 0.5 that had not been measured. On 600 local source files, pii-sentinel 0.5.1 blocked 64 files thathighdid not, and 49 of those were false positives. No category is named by default now, so the probabilities block nothing until a category is named inidentifiableThresholds. The level athigh, and adate_of_birthorpostal_addressfinding markedpii: true, still block under"identifiable". To get the old blocking back, setidentifiableThresholdsto0.5for each of the nine categories, or for only the ones you need, such as{ "sns_handle": 0.5 }.A company's address that pii-sentinel 0.5.0 or later reports with
pii: falsedoes not block through"identifiable". Thepii-address-jprule still blocks a Japanese postal address by its shape, a company's included.
v0.14.0 (2026-10-18)
Features
- A pii-sentinel server can block on identifiers and attributes, not only on its level:
"blockOn": "identifiable", experimental and opt-in. It blocks a file or prompt at levelhigh, as"high"does, and also when a category reaches its threshold whatever the level: an identifier (email_or_phone,government_id,financial_account,sns_handle,ip_address_of_a_person) or an attribute (postal_address,date_of_birth,employment_info,precise_location).person_namealone still passes. The reason names the category and says why it identifies a person, for exampledocument names a date of birth, which identifies a person once it is linked to a name, in this conversation or another, and adds; a name is presentwhen a name is also at or above 0.5.identifiableThresholdssets the probability each category blocks at, from 0 to 1, with 0.5 for every category; a name outside the list is refused. The 0.5 defaults are placeholders until the measured thresholds from coo-quack/pii-sentinel#46 are reported, which is why the option is opt-in. Measured on the evaluation set (coo-quack/pii-sentinel#46), no attribute category reaches the bar for blocking (precision 0.7, recall 0.5, ten positives), so for now the identifier categories carry the option and the labelled-attribute rules carry the attributes. With pii-sentinel 0.5.0, adate_of_birthorpostal_addressfinding markedpii: trueblocks under"identifiable"on its own, whatever the level;employment_info, handles, IP addresses and member numbers do not, and with 0.4.0 nothing changes."high"and"low"behave as before, and[allow-pii]lifts an identifiable block as it lifts any pii-sentinel block
Documentation
- The README's PreToolUse section now says which tool inputs are not scanned.
WebFetchandWebSearchare not in the default matcher, so the hook never runs for them and a URL carrying a key is not checked. MCP tools are in the matcher, but the hook reads only their path fields and command fields, so a key in another argument, such as a message body, is not checked. Widening the matcher toWebFetch|WebSearchstarts the hook for those calls without scanning them, since the hook reads nothing from them; scanning URLs needs a change to what the hook reads
v0.13.1 (2026-10-18)
Fixes
- A value the pii-sentinel server found was written to the block reason in full. A file blocked before it was read named each finding with its value, so a name or other PII the server judged sensitive reached Claude and the terminal unmasked. The value is now redacted as a rule finding's is:
Taro Yamada-Secret-Nameshows asTa****me. The finding's type is shown only when it is a short identifier; text in its place is shown asfinding - A prompt nested more than four levels deep was not scanned:
{"prompt":{"a":{"b":{"c":{"d":{"e":"key=AKIA…"}}}}}}passed, because an object at the depth limit was dropped with everything under it. The walk has no depth limit now. It reads every string in the prompt with an explicit stack, so the key is blocked at any depth, and its work is linear in the size of the prompt - The mask refusal named only the category the mask tag covers: with
[mask-secret], a key and an e-mail address, it listed the key and suggested[allow-secret], so the next attempt was blocked again for the address. It now lists every finding, up to fifty as a block does, and suggests the allow tag for each category, and still says which mask tag was used and that masking is not supported - A
.envfile under a name in another case was read:cat .ENVandRead .Env.localwere not blocked, because the name guard compared the name as written. The name is now compared without regard to case, and the template names are exempt in any case, so.ENV.exampleis read as.env.exampleis - A
.envfile read through a symbolic link was not blocked:ln -s .env link.txtfollowed byRead link.txtpassed the name check, since the guard looked only at the name the link was given. The guard now also looks at the file the link resolves to, so a link namednotes.txtto.envis blocked, and a link named.envis blocked by its own name. A link to a template is exempt by the template's name. A dangling link, a loop or a missing file keeps the name it was given, and nothing is read through it cp .env notes.txtwas not blocked: the copy has a name the guard does not know.cp,mv,ln,installandrsyncnow read their sources, which are every operand but the last, andtar -c(withbsdtarandgtar) andzipread the files they archive. The archive's own name and the destination are not read,tar -xreads none of its operands, andcp -t DIR SRC...takes every operand as a source. The names in atar -Torrsync --files-fromlist are sources too, andtar -C DIRmakes the names after it relative to DIR- A UTF-16LE file without a byte-order mark was missed when its first 16 KiB held fewer than eight NULs: a file of 9,000 kana followed by
AWS_ACCESS_KEY_ID=…was read as UTF-8, and the key, split by NULs, matched nothing. When the sample is undecided and a NUL appears in the bytes read, both UTF-16 byte orders are now scanned alongside the UTF-8 reading, each where it reads as text. A UTF-8 file with a single stray NUL is still scanned as UTF-8 - A run of spaces or tabs made the scan take 12 seconds and then fail closed. The
pii-emailexemption for an address afterssh,scp,rsyncorsftpwalked back over each run of white space from every position: 20,000 spaces took 478 ms, 50,000 took 2.9 s and 100,000 took 12.4 s, whenscan()threwScanBudgetExceeded. Now the white space between words is at most 16 characters and each argument at most 64, so 200,000 spaces, 200,000 tabs and 100,000apairs each scan in about 20 ms.ssh deploy@bastion.analytical-engines.organdscp build.tar ops@files.analytical-engines.org:/srv/are still not findings. An address after an argument of 65 characters, or after 17 spaces, is a finding now;docs/rules.mdstates the bounds - Unclosed synthetic tags made
userTypedTextquadratic: 20,000 unclosed<command-name>(280 KB) took 0.8 to 4.6 s here, and the function runs on every user line of a transcript. The four regular expression passes are now one linear pass, and the same input takes 0.2 ms. Paired and unclosed elements and fences are removed as before - The
pii-sentineltimeout restarted with every byte, althoughdocs/pii-sentinel.mddescribestimeoutMsas the wait for one hook call in total. A server that wrote one byte every 100 ms and never finished held the hook for more than 5 seconds withtimeoutMsset to 300 ms. The call now ends attimeoutMsin total: 305 ms in the same probe, with the connection closed
v0.13.0 (2026-10-18)
Features
- A date of birth after a label was not found:
DOB: 1990-05-06,D.O.B. 05/04/1991,Date of birth (form B): 03/01/1990,生年月日:昭和63年7月21日and誕生日は平成元年1月1日. The newpii-date-of-birthrule finds a calendar date afterDOB,D.O.B.,date of birth,birthdate,生年月日or誕生日, where the words of an English label may be joined by spaces, underscores or hyphens (date_of_birth,birth-date) or run together in camelCase (dateOfBirth), in ISO, slash, dot, English month-name, 年月日 (with a western or an era year) andS63.7.21forms. A bare date is not found, since it has no label:2024-01-15 release notesandorder date 2024-03-01are not. Neither arebirthday party on 2026-11-02(birthday is not a label),誕生日会は2026年11月14日(誕生日会 is a party, and the date after it is the party's) orDOB 2099-01-01(a future date). The 1970 and 1900 defaults are not found either, because forms write them in place of a date:DOB 01/01/1970 is a placeholderandDOB: 1900-01-01. Kanji numerals are read too, in the era and 年月日 forms:昭和五十年十二月三日,平成元年一月一日and the western year一九八八年七月二十一日. A date in a table is found from its column, when the header isdob,birth date,date of birthor another label of this rule and the table is CSV, TSV, a Markdown table or a SQLINSERT:name,doboverLena Vogt,18.07.1961, andINSERT INTO users (id, birth_date) VALUES (1, '1984-09-30'). Not found: an empty cell, a header with no rows, a column whose name is not a label (version,releasedover1.2.3,2024-01-15), a 1970 placeholder in adobcolumn, and adobline with no delimiter, which is not a table. Adobcolumn is trusted, sodob,versionover2024-01-15,1.2.3finds the date - A member, customer or account number after a label was not found:
会員番号:ABC-12345678,顧客番号 0001234567,customer ID 12345,member ID POL-WCYAPQ2N4FYandMy account number is AC-924089.. The newpii-customer-numberrule finds a value of five to twenty-four letters, digits and hyphens, with at least two digits; it starts and ends with a letter or digit, and a longer run is not a value, after the label.account IDis not one of its labels, and neither isorder number. A label that is the tail of a longer snake_case key is not found either:organization_membership_id: "2c159c8f5eecbb28c233367b"is a membership record's key, andbilling_customer_id: 100042is a foreign key. The underscore before the label keeps them out, and the cost is that a member number under a composite key such asbilling_customer_idis not found.口座番号is a label now. A Japanese label needs no boundary after it, so会員番号MBR-3PW8D6YTis found, and the value may use full-width digits (会員番号:12345678). An English label still needs one:customerIdx 12345is not found. A cell under a header named as one of these labels is read too:customer_number,nameoverC-0007710,Erika Lund, a Markdown table with顧客番号, andINSERT INTO members (membership_number, name) VALUES ('MBR-5TR2N8WC', 'Oskar Lind'). Not found:customer_id,nameover1000,Erika, since four characters is below the minimum, andmember,countoveralice,12345, sincememberalone is not a label - A Japanese postal address was not found:
東京都渋谷区神宮前5-28-11,大阪府大阪市北区梅田一丁目一番一号,福岡県糟屋郡粕屋町仲原2500-1and愛知県名古屋市中村区名駅4丁目3番2号グランドハイツ経堂210号室, where the match stops after 号 and before the building. The newpii-address-jprule finds a prefecture, a municipality and a block number in sequence, with the numbers in ASCII, full-width or kanji. A prefecture alone (東京都), a municipality alone (東京都千代田区), a count (東京都渋谷区で2-3件) and a dangling hyphen (東京都渋谷区神宮前5-) are not found. A company's footer address is found the same way as a person's, since the text does not say whose it is:株式会社ホライズン技研 本社:東京都港区虎ノ門一丁目2番3号is one. Issue #336 leaves that open - A value under a labelled column was not read, because a regex cannot reach a value in the row under a header. Two optional rule fields now do it:
columnLabelsnames the header cells, andcellRegexis the value in the cell under them. The header is CSV, TSV, a Markdown table or a SQLINSERTcolumn list, and the header's delimiter fixes the table: a CSV cell may span lines or contain a|or a tab, a stray quote does not run a record to the end of the text, a blank line does not end a table, and a SQL value may contain a)inside quotes. The built-in date-of-birth and member-number rules use both fields. The pass is code rather than a regex, so each character is read once
v0.12.0 (2026-10-18)
Features
- A password passed on a command line was not found:
mysql -h 10.2.0.14 -u report -p'V1m*23zdFGB' analytics,pg_dump --host db1 --username app --password 'S4Kv&DEu0GBmb' prodandsync.exe --password "jQMhIKJNlyJl@u#Ulf" --user fwagner. The newpassword-cli-flagrule finds--password,--passwdand--passwith=or a space, and the mysql-p'…'and-p"…"forms.--password-stdinandpg_dump --password prodare not found, becauseprodis not a password - A password in a
.netrcfile or avault kv getlisting was not found:password Salt_lVi9UMB45and an indentedpassword V9N7z2!pjcRsline. The newpassword-netrcrule finds the line - A password written in a sentence was not found:
my password is CKj3Jg%V8Yr if you want,password = "Summer2024!",pw: hunter2!x, and the Japaneseパスワードは … です,パスワード:…. The newpassword-proserule finds them.password is required,the password was resetandパスワードは設定済みですare not found, because the value has no letter with a digit or a symbol. Source code that names a password is not found either:password = hash_password(raw),password = getpass("Enter password: "),pwd = os.getcwd()andpassword: z.string().min(8),are calls, andpassword = new_passwordandpassword: form.password2are names. An unquoted identifier after:or=is not found as a result, sopassword: hunter2is not; quoting it, or adding a symbol, makes it one - A password under a JSON key was not found:
"db.pass": "i!kyBV7DyKVcqNoixmQ". The newpassword-json-keyrule finds a value whose key ends inpass,passwd,passwordorpwd, when that word begins at a.,_,-or a capital letter.bypassis not one of those keys - A Redis password was not found:
127.0.0.1:6379> AUTH hMDvD-xED3A, the MONITOR form"AUTH" "Ember%0eWzV6g4lU59"andredis-cli -h … -a …. The newredis-authrule finds them - An HTTP Basic credential was not found:
Authorization: Basic c3ZjX2JpbGxpbmc6Nk16WDluRVcrVCtMTGM=, which decodes tosvc_billing:6MzX9nEW+T+LLc=. The newhttp-basic-authrule finds it, and it refuses a value that does not decode to auser:passwordpair with both halves non-empty. A pair made only of the words every tutorial uses, such asuser:pass,admin:passwordandroot:test, is a documented example and is not found. Both halves have to be on that list, soadmin:Xk9mP2qR7vis still found - An HTTP Bearer token was not found:
Authorization: Bearer sk-live-OqDqQa5ZD5sRIkeC8wLtO9BS. The newhttp-bearer-tokenrule finds a token of twenty or more characters with entropy of at least 3.5.Bearer $TOKEN,Bearer <token>andBearer ${API_KEY}are not found - An AWS secret access key written without a label was not found, for example in the credentials CSV row
svc-report-1,AKIACHX6LHWTOH6XG21B,/yvGmsLPhuxamXfuIt4mxDZncVBbdM4d4bs6SuZc, or on the line after the key ID. The newaws-secret-near-key-idrule finds a 40-character value within 80 characters of an Access Key ID, when it mixes upper case, lower case and digits. A forty-character git commit id beside a key ID is not found, and neither is a key ID ending inEXAMPLE - A Docker Hub personal access token was not found:
docker login -u chatalab -p dckr_pat_…. The newdocker-hub-patrule finds thedckr_pat_prefix followed by at least 27 characters of letters, digits,_and- - An IBAN was not found:
DE89 3704 0044 0532 0130 00andGB82WEST12345698765432. The newpii-ibanrule finds them, with the length for the common countries and the mod-97 check digits; a changed check digit is not found - An international phone number was not found:
+49 151 85280948,+33 6 22 81 96 59,+81 90-9384-1967. The newpii-phone-intlrule finds a+and a country code followed by nine to fifteen digits.+2026-10-09,version +3.2.1anda+44 7801 997035are not found - A UK National Insurance number in its spaced form was not found:
NI: RE 62 91 56 A. The newpii-nino-ukrule finds it. The unspaced form is not covered, since those letters and digits are too common in identifiers
Fixes
- An email address was missed when a full stop followed it:
contact dmitri.ferreira@fernhill.co.was not found, because the rule read the stop as the start of a longer domain. The domain is now taken whole up to the stop, soa@b.co.ukis still one address and the stop is not part of it. The same rule now also finds an address that ends a sentence, such asmaintainers@opensource.org., which was missed before - An email address with its
@percent-encoded was missed:GET /api/users?email=tomas.reyes%40quillfield.com&expand=orderswas not found.%40is now accepted in place of the@, andgit%40is refused as a repository address in the same waygit@is - Role addresses were blocked as if they were a person's:
noreply@,support@,sales@,info@and the other function mailboxes (no-reply,donotreply,do-not-reply,help,contact,press,abuse,postmaster,webmaster,hostmaster) are no longer found, in any letter case. The local part is compared whole, sojohn.support@x.comandsupport2@x.comare still found - A Telegram bot token in a URL was not found:
https://api.telegram.org/bot123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/getMe, because the word boundary before the digits does not hold afterbot. The rule now also accepts the digits directly afterbot; the:AAshape is still required, sorobot123456789:AA…is not found - A Redis connection string with no user name was not found:
redis://:4yBnLzQgJuCkCCJsJ7@cache-03.northgate-data.io:6379/0. The user name may now be empty.redis://:@host, with no password either, is still not found - A My Number was reported in an AWS ARN:
arn:aws:iam::835997400938:role/ci-deployerandarn:aws:sns:us-east-1:835997400938:topicwere blocked whenever the account ID happened to pass the check digit. A twelve-digit account ID in the fifth field of an ARN is no longer read as a My Number; the same digits elsewhere still are - A password assigned to a fake value in a test was blocked:
const password = 'fake-token-for-unit-tests'.tests,mockandstubare now placeholder words, andunit,onlyanduseare filler words, so this value is a placeholder - A placeholder built from one repeated character was blocked as a real key:
ghp_xxxx…,glpat-XXXX…,xoxb-111111111111-222222222222-XXXX…andAC000…were all found. Such a value, a short letter prefix followed by runs of one character, is now a placeholder. A random key is still found, including one with a run of four to seven of the same character inside it - The values vendors print in their own documentation were found: AWS's
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY, the test secret key in Stripe's API reference and the jwt.io token. Each is now a placeholder when it stands alone, in any quotes. A value that begins with one of them, such as the AWS example with a character added, is still found - A test card number was blocked as a real card:
4000 0000 0000 0002,2223 0031 2200 3222and the other published Stripe test numbers that passed Luhn but were missing from the list (36227206271667,6200000000000005) are no longer found. A Luhn-valid number that is not published test data is still a card - A secret that began with a slash was taken for a path: a 40-character AWS secret access key that starts with
/was not counted as a secret. A value is now a path only when it has a second separator, or starts with~/or./, so/etc/ssl/keyis a path and/yvGmsLPhuxam…is not - A US number reserved for fiction, or a toll-free one, was blocked as a person's:
(212) 555-0100,(555) 867-5309,1-800-555-1212and(800) 123-4567are no longer found. Area code 555, lines 555-0100 to 555-0199, 555-1212 and the toll-free area codes (800, 888, 877, 866, 855, 844, 833) are skipped.(212) 555-0300and(415) 867-5309still are
v0.11.0 (2026-10-13)
Breaking changes
- A group-writable directory on the path to the pii-sentinel socket is accepted only when
/etc/nsswitch.confnames no source other thanfilesandsystemdforpasswd,groupandinitgroups, and, withsystemd, only when its static records under/etc/userdband the other directories it reads put no one else in the group. The group was judged from/etc/groupand/etc/passwdalone, but LDAP, sssd, NIS, winbind and nss-systemd can add members those files do not show, and a local group's gid matching a directory group's is an ordinary accident. This refuses group-writable socket directories where nsswitch.conf listssss,ldapor another such source (Fedora's default listssss);chmod g-w <directory>fixes it (#310) - A socket the check refuses (a directory or socket another user could have put in place, or an abstract name) blocks even with
"onUnavailable": "allow". It was reported like a server that did not answer, so with"allow"pii-sentinel was skipped without a word for as long as the socket stayed there, while the server behind it may have been running. The block reason now says why the socket was refused and how to fix it, instead of suggesting to start the server, and a refused group-writable directory names its group (#314)
Fixes
- A trailing comment hid what a command does:
env # listandprintenv # allwere read as running a command, so the environment was not scanned, andgrep -r foo # searchandrg TODO # xas naming files, so the working directory was not checked for env files. Every command with a comment is now also read with the comment removed, as the shell runs it (#308) - In inline code (
python3 -c,node -e, and a tool'scodefield), a quote in a comment (# don't,/* it's */) paired with the next quote and shifted every pair after it, so a path read later in the code was not scanned. Every quote is now tried as the opening one (#309) export,export -p,set,declare,typesetand zsh'slocalprint every variable when given no names, but onlyenvandprintenvwere treated as printing the environment, so the values went unscanned. They are now, behindbuiltin,commandandtimetoo, anddeclare -p NAME, zsh'stypeset NAMEand the like scan the variables they name; an operand only the shell's expansion resolves (declare -p "$v") counts as a dump (#313)- An environment dump in a function written with the
functionkeyword (function f { env; }) was not read, becausefunctionwas taken for the command. Neither was a program handed to a shell on stdin:echo env | bash,printf 'e%sv' n | sh,cat <<'EOF' | bash,bash <<< env,echo env | source /dev/stdin, and a heredoc fed to a local shell or interpreter (bash <<'EOF',python3 - <<'EOF'), whose body was skipped as text. Program text written on the command line is now read the waybash -cis, for files as well as the environment, withprintfformats and backslash escapes applied (#316) - Inline code that prints the environment in its own language was not read as an environment dump:
python3 -c 'print(os.environ)',node -e 'console.log(process.env)',ruby -e 'p ENV',perl -e 'print %ENV'andphp -r 'print_r(getenv());'printed every value unscanned, andos.environ["TOKEN"]and the like went unscanned too. Code run with-c,-eor-r, handed over on stdin, or sent in a tool'scodefield is now read for both, andnode -p/bun -pare read as inline code (#317) - Inline code was read only behind an interpreter's plain name and an exact flag:
python3.12 -c,perl5.34 -eand the like, Python's bundled switches (python3 -Ic,-Sc) anddeno evalran their code unscanned. Versioned names now read as the interpreter, Python's switches are read the way the shells' are, and every operand afterdeno evalis read as code.python3 -E script.pyno longer reads the script name as code (#319) - An in-place edit by
perl -pi -eorruby -pi -ewas passed over whole, code included, though only the files go back to disk:perl -pi -e 'BEGIN { print %ENV }' f.txtprinted the environment unscanned. The code is now read; the files it edits are still not
v0.10.0 (2026-10-11)
Breaking changes
- The
piiSentinelentry no longer acceptsurl. The hook cannot tell whether the program listening on a TCP port is your pii-sentinel server, so file and prompt text could go to another local program that answers"none". This breaks configs that seturl: they now block with a reason that names the config file and saysurlwas removed in v0.10.0, untilurlis replaced bysocket, the path to the server's Unix socket
Fixes
- A path built from an environment variable (
cat "$TOKEN/.env") was shown in the block reason after the variable was expanded, so the variable's value reached Claude unmasked; the reason now shows the path as written ($TOKEN/.env) - The hook checks the pii-sentinel socket before it sends any text and refuses one another user could have put in place: a directory other users can write to (unless it has the sticky bit), or a socket not owned by you or root. The README and the pii-sentinel page now use
~/.pii-sentinel/pii-sentinel.sockin a directory only you can write to, instead of the fixed/tmp/pii-sentinel.sock, and say that the config needs the absolute path - A file read through a redirection bash joins to its operator was not collected:
cat 2>&1 .env,cat &>/dev/null .env,cat >|/dev/stdout .envand the same with a line continuation inside the operator all let the file through, because the&or|was taken for a separator. These operators are now scanned as bash reads them, and the POSIX reading (dash -c 'true &>/dev/stdout cat .env') is still scanned too - A
#comment holding an unbalanced quote, a backquote, a$(or a<<hid the rest of the command: the quote or heredoc ran on past the newline, so# Check what's configuredfollowed bycat .envcollected no paths and the file was printed unscanned. Such a comment is now found the way bash finds it, and the command is read a second time with the comment removed, so the files the lines after it name are scanned; commands whose comments hold none of that read as before. Thecdthat sets the directory relative paths resolve against is now read with comments removed too, so# it'sbeforecd configno longer hides thecd, andcd config # ; cd ..no longer follows thecd ..in the comment
CI
- The release job, which publishes to npm, runs in the
npm-releaseenvironment, which onlymainmay deploy to. npm's trusted publishing checks the repository and workflow file but not the branch, so arelease.ymledited on another branch could publish a release without review
v0.9.1 (2026-10-04)
Documentation
- The README's install check used AWS's documented
AKIAIOSFODNN7EXAMPLE, which this tool allows on purpose, so a working install looked broken. It now uses the private-key header the documentation site already uses - Example block messages showed a masked documentation key; they now show a key that is actually blocked
- The README describes where pii-sentinel sits in each hook, lists
transcript.tsandpii-sentinel.ts, and no longer says the transcript is read from a 64 KB tail; the site links the pii-sentinel page from the installation, getting-started and home pages
v0.9.0 (2026-10-03)
Features
- Optionally ask a running pii-sentinel server about each file read and each prompt, after the rules, with a
piiSentinelentry in the config file. It judges documentsnone,loworhighand blocks athighby default;[allow-pii]lifts it. Without the entry nothing changes, and nothing is installed or started
Fixes
- An allow tag in the prompt was not honoured on the first tool call when the runtime had written more than 64 KB after it. Claude Code now writes its attachments (skill list, memory, CLAUDE.md) as transcript lines after the prompt, often over 100 KB, so
[allow-pii]and the others did nothing until the next turn. The transcript is now read back from the end until the latest user line, up to 8 MiB
Maintenance
- Publish to npm with trusted publishing (OIDC) instead of a long-lived token (#268)
- Remove the
deprecate.ymlworkflow, the last user of that token. Deprecating a version is now a manualnpm deprecatewith 2FA (see CONTRIBUTING.md)
v0.8.2 (2026-09-26)
Documentation
- Update configuration pointers, add a block check for git-clone installs, and note the moved plugin docs URL
- Align rules.md with the actual azure-sas-key pattern and merge the duplicate cloud sections
Maintenance
- Update @biomejs/biome to 2.5.14 (#258, #252, #242, #238)
- Update @types/node to 25.9.8 (#260, #257, #250)
- Update vitest to 5.0.1 (#255, #244)
- Update pnpm to 12.6.0 (#263, #261, #256, #253, #246, #240, #235)
- Update github/codeql-action to v4.38.2 (#264, #259, #251, #236, #228)
- Update pnpm/action-setup to v6.1.0 (#247)
- Update actions/deploy-pages to v5.0.1 (#241)
- Update postcss to v8.5.28 (#245, #243)
- Update node to v24.21.0 (#249)
- Lock file maintenance (#262, #254, #248, #239, #234)
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