Skip to content

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} and pii-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, including 1985年3月12日, 1985년 3월 12일 and the Korean 1985. 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. The birth-date validator 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-de and pii-address-es, beside the Japanese pii-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 and deployed via Jenkins 12 times, 10000 Builds is 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, since Musterweg 3 kostet 12000 Euro is 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 that high did 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 that high did 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 in identifiableThresholds. The level at high, and a date_of_birth or postal_address finding marked pii: true, still block under "identifiable". To get the old blocking back, set identifiableThresholds to 0.5 for 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: false does not block through "identifiable". The pii-address-jp rule 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 level high, 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_name alone still passes. The reason names the category and says why it identifies a person, for example document 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 present when a name is also at or above 0.5. identifiableThresholds sets 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, a date_of_birth or postal_address finding marked pii: true blocks 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. WebFetch and WebSearch are 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 to WebFetch|WebSearch starts 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-Name shows as Ta****me. The finding's type is shown only when it is a short identifier; text in its place is shown as finding
  • 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 .env file under a name in another case was read: cat .ENV and Read .Env.local were 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.example is read as .env.example is
  • A .env file read through a symbolic link was not blocked: ln -s .env link.txt followed by Read link.txt passed 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 named notes.txt to .env is blocked, and a link named .env is 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.txt was not blocked: the copy has a name the guard does not know. cp, mv, ln, install and rsync now read their sources, which are every operand but the last, and tar -c (with bsdtar and gtar) and zip read the files they archive. The archive's own name and the destination are not read, tar -x reads none of its operands, and cp -t DIR SRC... takes every operand as a source. The names in a tar -T or rsync --files-from list are sources too, and tar -C DIR makes 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-email exemption for an address after ssh, scp, rsync or sftp walked 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, when scan() threw ScanBudgetExceeded. 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,000 a pairs each scan in about 20 ms. ssh deploy@bastion.analytical-engines.org and scp 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.md states the bounds
  • Unclosed synthetic tags made userTypedText quadratic: 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-sentinel timeout restarted with every byte, although docs/pii-sentinel.md describes timeoutMs as 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 with timeoutMs set to 300 ms. The call now ends at timeoutMs in 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 new pii-date-of-birth rule finds a calendar date after DOB, 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) and S63.7.21 forms. A bare date is not found, since it has no label: 2024-01-15 release notes and order date 2024-03-01 are not. Neither are birthday 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) or DOB 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 placeholder and DOB: 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 is dob, birth date, date of birth or another label of this rule and the table is CSV, TSV, a Markdown table or a SQL INSERT: name,dob over Lena Vogt,18.07.1961, and INSERT 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,released over 1.2.3,2024-01-15), a 1970 placeholder in a dob column, and a dob line with no delimiter, which is not a table. A dob column is trusted, so dob,version over 2024-01-15,1.2.3 finds the date
  • A member, customer or account number after a label was not found: 会員番号:ABC-12345678, 顧客番号 0001234567, customer ID 12345, member ID POL-WCYAPQ2N4FY and My account number is AC-924089.. The new pii-customer-number rule 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 ID is not one of its labels, and neither is order 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, and billing_customer_id: 100042 is a foreign key. The underscore before the label keeps them out, and the cost is that a member number under a composite key such as billing_customer_id is not found. 口座番号 is a label now. A Japanese label needs no boundary after it, so 会員番号MBR-3PW8D6YT is found, and the value may use full-width digits (会員番号:12345678). An English label still needs one: customerIdx 12345 is not found. A cell under a header named as one of these labels is read too: customer_number,name over C-0007710,Erika Lund, a Markdown table with 顧客番号, and INSERT INTO members (membership_number, name) VALUES ('MBR-5TR2N8WC', 'Oskar Lind'). Not found: customer_id,name over 1000,Erika, since four characters is below the minimum, and member,count over alice,12345, since member alone is not a label
  • A Japanese postal address was not found: 東京都渋谷区神宮前5-28-11, 大阪府大阪市北区梅田一丁目一番一号, 福岡県糟屋郡粕屋町仲原2500-1 and 愛知県名古屋市中村区名駅4丁目3番2号グランドハイツ経堂210号室, where the match stops after 号 and before the building. The new pii-address-jp rule 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: columnLabels names the header cells, and cellRegex is the value in the cell under them. The header is CSV, TSV, a Markdown table or a SQL INSERT column 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' prod and sync.exe --password "jQMhIKJNlyJl@u#Ulf" --user fwagner. The new password-cli-flag rule finds --password, --passwd and --pass with = or a space, and the mysql -p'…' and -p"…" forms. --password-stdin and pg_dump --password prod are not found, because prod is not a password
  • A password in a .netrc file or a vault kv get listing was not found: password Salt_lVi9UMB45 and an indented password V9N7z2!pjcRs line. The new password-netrc rule 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 new password-prose rule finds them. password is required, the password was reset and パスワードは設定済みです 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() and password: z.string().min(8), are calls, and password = new_password and password: form.password2 are names. An unquoted identifier after : or = is not found as a result, so password: hunter2 is not; quoting it, or adding a symbol, makes it one
  • A password under a JSON key was not found: "db.pass": "i!kyBV7DyKVcqNoixmQ". The new password-json-key rule finds a value whose key ends in pass, passwd, password or pwd, when that word begins at a ., _, - or a capital letter. bypass is 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" and redis-cli -h … -a …. The new redis-auth rule finds them
  • An HTTP Basic credential was not found: Authorization: Basic c3ZjX2JpbGxpbmc6Nk16WDluRVcrVCtMTGM=, which decodes to svc_billing:6MzX9nEW+T+LLc=. The new http-basic-auth rule finds it, and it refuses a value that does not decode to a user:password pair with both halves non-empty. A pair made only of the words every tutorial uses, such as user:pass, admin:password and root:test, is a documented example and is not found. Both halves have to be on that list, so admin:Xk9mP2qR7v is still found
  • An HTTP Bearer token was not found: Authorization: Bearer sk-live-OqDqQa5ZD5sRIkeC8wLtO9BS. The new http-bearer-token rule finds a token of twenty or more characters with entropy of at least 3.5. Bearer $TOKEN, Bearer <token> and Bearer ${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 new aws-secret-near-key-id rule 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 in EXAMPLE
  • A Docker Hub personal access token was not found: docker login -u chatalab -p dckr_pat_…. The new docker-hub-pat rule finds the dckr_pat_ prefix followed by at least 27 characters of letters, digits, _ and -
  • An IBAN was not found: DE89 3704 0044 0532 0130 00 and GB82WEST12345698765432. The new pii-iban rule 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 new pii-phone-intl rule finds a + and a country code followed by nine to fifteen digits. +2026-10-09, version +3.2.1 and a+44 7801 997035 are not found
  • A UK National Insurance number in its spaced form was not found: NI: RE 62 91 56 A. The new pii-nino-uk rule 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, so a@b.co.uk is still one address and the stop is not part of it. The same rule now also finds an address that ends a sentence, such as maintainers@opensource.org., which was missed before
  • An email address with its @ percent-encoded was missed: GET /api/users?email=tomas.reyes%40quillfield.com&expand=orders was not found. %40 is now accepted in place of the @, and git%40 is refused as a repository address in the same way git@ 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, so john.support@x.com and support2@x.com are 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 after bot. The rule now also accepts the digits directly after bot; the :AA shape is still required, so robot123456789: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-deployer and arn:aws:sns:us-east-1:835997400938:topic were 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, mock and stub are now placeholder words, and unit, only and use are 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… and AC000… 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 3222 and 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/key is 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-1212 and (800) 123-4567 are 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-0300 and (415) 867-5309 still 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.conf names no source other than files and systemd for passwd, group and initgroups, and, with systemd, only when its static records under /etc/userdb and the other directories it reads put no one else in the group. The group was judged from /etc/group and /etc/passwd alone, 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 lists sss, ldap or another such source (Fedora's default lists sss); 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 # list and printenv # all were read as running a command, so the environment was not scanned, and grep -r foo # search and rg TODO # x as 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's code field), 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, typeset and zsh's local print every variable when given no names, but only env and printenv were treated as printing the environment, so the values went unscanned. They are now, behind builtin, command and time too, and declare -p NAME, zsh's typeset NAME and 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 function keyword (function f { env; }) was not read, because function was 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 way bash -c is, for files as well as the environment, with printf formats 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' and php -r 'print_r(getenv());' printed every value unscanned, and os.environ["TOKEN"] and the like went unscanned too. Code run with -c, -e or -r, handed over on stdin, or sent in a tool's code field is now read for both, and node -p / bun -p are 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 -e and the like, Python's bundled switches (python3 -Ic, -Sc) and deno eval ran their code unscanned. Versioned names now read as the interpreter, Python's switches are read the way the shells' are, and every operand after deno eval is read as code. python3 -E script.py no longer reads the script name as code (#319)
  • An in-place edit by perl -pi -e or ruby -pi -e was passed over whole, code included, though only the files go back to disk: perl -pi -e 'BEGIN { print %ENV }' f.txt printed the environment unscanned. The code is now read; the files it edits are still not

v0.10.0 (2026-10-11) ​

Breaking changes ​

  • The piiSentinel entry no longer accepts url. 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 set url: they now block with a reason that names the config file and says url was removed in v0.10.0, until url is replaced by socket, 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.sock in 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 .env and 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 configured followed by cat .env collected 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. The cd that sets the directory relative paths resolve against is now read with comments removed too, so # it's before cd config no longer hides the cd, and cd config # ; cd .. no longer follows the cd .. in the comment

CI ​

  • The release job, which publishes to npm, runs in the npm-release environment, which only main may deploy to. npm's trusted publishing checks the repository and workflow file but not the branch, so a release.yml edited 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.ts and pii-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 piiSentinel entry in the config file. It judges documents none, low or high and blocks at high by 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.yml workflow, the last user of that token. Deprecating a version is now a manual npm deprecate with 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 <secrets with no space, a cat on the second line of a multi-line command, (cat secrets), while cat secrets; do :; done, and echo $(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 had secrets skipped 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 $TOKEN and ${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, the z*cat family, 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 -i and ruby -i (including bundled forms such as perl -pi -e and perl -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, so sed -Ei counts while perl -Ilib -pe and perl -MList::Util -pe are reads rather than in-place edits. A letter the list does not know stops the reading and the file is scanned. grep -i is unaffected — its -i is 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 leading VAR=value assignment, 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 catches python3 -c "open('.env').read()"
  • Scan the file operands of git subcommands that print contents (show, diff, blame, annotate, grep, cat-file) and of dd if=. git log counts 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 env or printenv would 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 Read and Bash, Grep and 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, notebookpath and sourcepath, compared with separators and case removed so that file_path, filePath and filepath are 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 under target, document or uri. 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 becomes Read|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=1 to 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 from hookSpecificOutput rather than a top-level decision field, 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 .env via cat > deploy.sh <<EOF is 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-base64 rule. -----BEGIN never appears in the text, so client-key-data in a kubeconfig, tls.key in 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-auth rule — a git remote, a .netrc, a private registry, a curl invocation. RFC 3986 §3.2.1 deprecates the form for the same reason. The placeholder machinery already covers the near neighbours, so https://user:password@localhost, https://x-access-token:${GH_TOKEN}@… and https://USERNAME:PASSWORD@example.com stay 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 listed mongodb but not the SRV scheme, which is the one MongoDB Atlas hands out
  • Recognise the AWS key prefixes ABIA, ACCA, APKA and ASCA alongside 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 detectUtf16 to 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 .env in 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 .env took 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 pattern and Grep {pattern} with no path all 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 plain rg TODO in four of twelve checkouts
  • Name NotebookRead in the hook matcher. It reached the hook only because Read is a substring of it, and the notebook_path field 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 secrets resolved to the cat sitting in echo's arguments and scanned a file the command never opens, so echo, printf, true, false and : end the descent
  • Leave the output file of sort -o out.txt in.txt and 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 65 range 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-sid had no word boundary, so a certificate fingerprint was an Account SID; telegram-bot-token capped its secret part at 40 and went invisible at 41; and pii-email excluded zip at 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. isNotSecretShaped waved through anything shaped a.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-string credentials too. [^@\s]+ crosses both : and /, so a line of mongodb:// 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-assignment pattern'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 repeated SECRET was 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 stat reports. 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/environ is 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. readFileSync has 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 secrets and sed -e's/a/b/' secrets scanned 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 the host:path form
    • a bare private address. Requiring a label lost 192.168.1.50, X-Forwarded-For: 10.0.0.5 and remote_addr=…; what says an address is a machine is the command around it, so that is what excludes it now, and a host:port pair 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_PASS counts as well as DB_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
  • Read a command that arrives as an argv array on the Bash tool too, not only on an MCP one. The same command was scanned or not depending on who sent it
  • Block a .env template 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.example and .env.big.example passed on their names after all
  • Expand ** as a single * rather than refusing it. Refusing it meant cat ** 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, clone or git@ is next to it, and example.com and the other RFC 2606 domains are nobody's mail
    • the published test card numbers are not cards
    • cap is 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_hosts a finding
    • a value that is a variable reference (PASSWORD: ${VAR}) names a secret rather than being one
    • .env.example, .env.sample, .env.template, .env.dist and .env.defaults are 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
  • [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
  • tail printed the part that was not scanned. The per-file cap reads the first megabyte; tail -2 app.log shows 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 windows
  • view and vimdiff print a file the way less does 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 .env with 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-jp validator 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: 0 is not the same as omitting it, an entropyThreshold above 8 rejects everything, flags: "y" matches only at the start of the text, and a large contextWindow widens excludeContext too
  • The same defect was still in env-assignment, and worse. Its value capture was open-ended, so a megabyte of TOKEN=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 runs rm -rf dist from inside dist, or a git 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 null parsed 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_PATH and TOKEN_HEADER_NAME describe a secret rather than holding one, and a key whose last word is PROJECT or ENDPOINT says 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, and postgres:postgres@ is what a compose file ships with
  • A context word is a label, not a fragment of the identifier beside the number. extract-zip supplied "zip" and golang.org/x/mobile supplied "mobile", so a version number beside either read as a postal code or a telephone number — which is to say lockfiles and go.sum could not be read at all. Nor could name@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-2024 is a date. A Japanese telephone number has ten digits or eleven, and 0120 belongs to a business
  • A megabyte of eyJ used 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. eyJ recurs 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 .env name 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-all was 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
  • Read with a file_path that 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.line or command line was walked past while file.path was 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-key became a strict subset of stripe-secret-key, and openai-project-key stopped 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 excludeContext field it used is documented now and still serves the postal code rule
  • The release could not publish. GitHub runs every run: step as bash -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 publish would upload, not the checked-out tree. Deleting "dist/" from the files field used to pass every gate while shipping a package whose hooks cannot start — verified by doing it, along with dropping hooks/ 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.example was 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, and example is 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.io was silently dropped. The exemption now covers the operand position only — ssh user@host and at most two arguments in between — and the host:path forms 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, sk or tk, 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's ya29. access tokens and 1// 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 Bash was 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 every mcp__* tool down that path. command, cmd, script and code are read now, the last for the paths quoted inside it
  • Treat an input of the wrong type as absent rather than throwing. A command that is a number, a prompt that is an object, a cwd that 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 cwd and nothing read it, so cat secrets.txt named a path relative to wherever the hook process happened to start and was dropped as a file that is not there. A literal cd earlier in the same command moves the base too, which is what cd build && cat secrets needs
  • Read an assignment written with : and with a lower-case name. The rule wanted [A-Z_] and =, so a docker-compose.yml full of POSTGRES_PASSWORD: …, an appsettings.json with "client_secret": … and an ~/.aws/credentials with aws_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) secrets left secrets in 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 .env name block only for a tag that allows secrets. parseAllowTags reads [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/credentials named a path that exists on no disk, so it was dropped as a file that is not there — and ~/.ssh/id_rsa, ~/.npmrc and ~/.netrc went 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 .env file, 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 .env name guard never ran, and a file really named report[2].txt was read as a character class and expanded to report2.txt
  • Read a shell's bundled -c. bash -lc 'cat secrets' runs what bash -c runs, and only the exact spelling was recognised, so the inline code went unparsed. The letters before the c have to be valueless switches
  • Step past eval the 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, which openssl genpkey -aes256 writes and the rule did not list
  • Expand a glob before deciding whether it names a file. cat sec* collected sec*, found nothing on disk by that name, and allowed the read; cat .env* did the same, one character away from cat .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 cat is cat reading secrets, 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 — while cat < secrets blocked
  • Scan the file named inside a git log -L range. -L1,10:secrets prints 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. In sed -- -i secrets, -i is the script and secrets is 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/zero never 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 — Read and a Bash command — .env and .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 secrets searches for -aws in secrets, but -aws was taken for a flag, so nothing marked the pattern as supplied and secrets was 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 $TOKEN whenever A is 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 with grep -rn '${TOKEN' . is blocked when that variable holds a secret. A false block, and the same direction the hook already errs in for echo '$TOKEN'
  • .claude-plugin/plugin.json declared 0.5.1 while package.json declared 0.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 < secrets was scanned while sha256sum < secrets was not, because only four of the family were listed. sha224sum, sha384sum, sha512sum, b2sum, shasum, md5 and sum join them
  • Read a directory when a tool is pointed at one. grep -r AKIA . and a Grep whose path names 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 $VAR and ${VAR} in a path. cat ~/.aws/credentials was blocked and cat $HOME/.aws/credentials was 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-token required the FlyV1 auth scheme, which is not part of the token, and excluded _ and - from a base64url body; linear-key, twilio-sid and postman-key were lower-case only against mixed-case and hex formats; notion-token, digitalocean-pat, gitlab-pat, square-access-token and huggingface-token each covered one of the prefixes their vendor issues; replicate-token omitted the hyphen; azure-sas-key fixed the length at 43 where Azure IoT DPS documents 16–64 byte keys; anthropic-key asked for 95 characters after the prefix where the format has 101, truncating the match; and twilio-sid had 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.md was 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-assignment matched process.env.API_TOKEN, user.password_digest and self.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. PreToolUse collected 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 EXAMPLE where the random part would end — AKIAIOSFODNN7EXAMPLE and 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 new aws-key validator 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.png satisfied the pattern because png is 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.md described the parse-error path as a fail-open that exits 0. It exits 2 — the security policy stated the protection backwards
  • docs/troubleshooting.md said 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 nothing
  • README.md said only the first 1 MiB of a file is scanned. Both ends are read; what is missed is the middle
  • docs/rules.md said any allow tag lifts the .env name block and passes the file through without scanning. [allow-pii] does not lift it, and the contents are scanned either way
  • docs/rules.md gave API_KEY=placeholder as a value the entropy threshold reports. It is not reported — the placeholder test drops it
  • The file structure in README.md listed two files under src/lib/, from before this release added three more and moved the rules into JSON
  • The development commands in README.md were npm, while CONTRIBUTING.md and every CI job are pnpm. 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 .ts file inside node_modules, so an npm install wired to src/ started the hook, failed with ERR_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 beside src/, the npm instructions point at it, and no type stripper or tsx download is involved. The plugin install keeps using the sources, which sit outside node_modules and work

  • Run the release path's own gates. release.yml is reached by a push to main and 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 plain node

  • Stop shipping the tests. files carried src/, which carried __tests__: more than half the tarball, and none of it useful to anyone installing

  • Add a versions job that fails when package.json and .claude-plugin/plugin.json declare different versions, or when either declares nothing that looks like one

  • Check vitest.config.ts the way src/ is checked. It sits at the repository root, and both tsc and biome were scoped to src, so the file that decides how the tests run was neither typechecked nor linted

  • Run CI on pushes to main and develop, 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 together

  • The release could not publish, again, and for a new reason. The smoke test now starts each hook through eval, and under the bash -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 pipefail does not clear -e, which arrives from the invocation. Measured: without set +e, eight errors and the step exits 1

  • Make 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 time

  • Run the release smoke test against the commands hooks/hooks.json declares, as well as against dist/. The manifest starts src/*.ts under type stripping, which is what a plugin install runs and what the gate only checked the existence of; dist/*.js is what the npm instructions point at. The commands are read back from the manifest so the two cannot drift apart

  • Fail the release when the marketplace catalog does not pin this plugin to main. /plugin install serves the entry's ref, and with no ref that is the repository's default branch — develop. Every gate in release.yml guards main and npm, and none of them was on the path a plugin user installs from, so a merge into develop reached users directly

  • Anchor the version shape test at both ends, in ci.yml and release.yml alike. ^[0-9]+\.[0-9]+\.[0-9]+ with no $ accepts anything at all after a valid prefix, and release.yml splices that value into four run: scripts: 0.8.0"; curl … | sh; echo ", 0.8.0 && rm -rf / and 0.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 from env: rather than by interpolation

  • Create 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.yml a concurrency group, so two pushes to main in quick succession cannot race over the tag and the publish. Nothing is cancelled: a release half-way through is worse than one that waits

  • Put a timeout-minutes on every job in both workflows. A hung step otherwise holds a runner for the six-hour default

  • Assert that the published UserPromptSubmit hook 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 green

  • Install with --frozen-lockfile in CI. A lockfile CI is allowed to rewrite is a lockfile CI does not check

  • Take 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 gap ci.yml had, and the branches it covers cannot be configured. The new workflow analyses pull requests, pushes to main and develop, 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 accepted

  • Fail the lint on warnings, and check docs/.vitepress/ the way src/ is checked. biome lint exits 0 on a warning, so the dead tokenize in src/lib/rules.ts — superseded by contextTokens, 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: useLiteralKeys contradicts this project's noPropertyAccessFromIndexSignature, and applying it broke the typecheck; noTemplateCurlyInString is 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 requireContext only 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
  • 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.json or set SENSITIVE_CANARY_CONFIG to 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
  • 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: force g flag on regex, validate regex field, 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 so secretGroup + validate works in user rules

Dependencies ​

  • Update pnpm to v11.19.0 and refresh the lockfile

v0.6.0 (2026-08-02) ​

Features ​

  • Add SENSITIVE_CANARY_CATEGORIES environment variable to limit which rule categories are active
    • Accepts secret, pii, secret,pii, or all (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 the secret category is not enabled

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 packageManager field 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 minimumReleaseAge to 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.json and sync marketplace via repository_dispatch on 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.json references from CONTRIBUTING.md and README.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 with openai-project-key and anthropic-key via negative lookahead
  • Use nullish coalescing (??) in entropy() for correct semantics under noUncheckedIndexedAccess
  • Remove unreachable unique filter in user-prompt-submit-hook
  • Consolidate randomBird() calls in block() for consistent emoji across terminal and JSON output
  • Fix fd leaks in file read and /dev/tty write paths with try/finally
  • Use bytesRead return value from fs.readSync to 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.md to root CONTRIBUTING.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-canary to @coo-quack/sensitive-canary
  • Homepage — added homepage field 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 repository and files fields, removed private: true for npm publishing
  • npm install docs — added npm install -g setup 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

Released under the MIT License.