SLOPSHOPPER

redact-secrets

Masks emails, phone numbers, API keys, passwords, cards, IBANs and national IDs in prompts before they are sent

newbandrowsguardcommandtoast
v0.6.0MITupdated 2026-10-26webdiego/redact-secrets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact-secrets
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ redact-secrets │ ⏺ Read(src/auth.ts) │ 🔒 Sensitive data masked before sending: │ ⎿ Read 6 lines │ 🔐 1 │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭────────────────────────────────────────────╮ ⏺ Bash(bun test) │ redact-secrets │ ⎿ 3 pass, 1 fail │ 🔒 Sensitive data masked before sending: │ │ 🔐 1 · 🔑 1 │ ● Done. refresh now rejects expired claims and logs an audit event. ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /redact-secrets ⎿ redact-secrets: redact-secrets: countries all, color purple ⟦•••⟧ redact-secrets · countries: all · color: purple [ Got it Emails, phones, API keys, passwords, cards, IBANs and national IDs are masked before they are sent. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟦•••⟧ redact-secrets · countries: all · color: purple Emails, phones, API keys, passwords, cards, IBANs and national IDs are masked before they ar
README

<img src="docs/banner.svg" alt="redact-secrets: mask sensitive data before it leaves your prompt" width="100%">

redact-secrets

A Claude Code mod that masks sensitive data in your prompts before they leave your machine.

Emails, phone numbers, API keys, passwords, credit card numbers, IBANs and national ID numbers typed (or pasted) into the Claude Code prompt, or found in what Claude's tools read and print (files, command output, web pages), are replaced with a short placeholder that keeps only their first 3 characters. The model never receives the full value. In your transcript the masked value is highlighted, and a toast tells you what was masked, in your system language.

Masked values highlighted in the Claude Code transcript, with the toast counting them

you type:     write to mario.rossi@gmail.com, tel +39 333 123 4567, my tax code is RSSMRA85T10A562S
model reads:  write to ⟦email:mar•••⟧, tel ⟦phone:+39•••⟧, my tax code is ⟦id:RSS•••⟧
toast:        🔒 Sensitive data masked before sending: 📧 1 · 📞 1 · 🪪 1

The images in this README are illustrations of the mod's output, drawn with the exact colors and text it uses; they are not screen captures.


Table of contents


Why

It is easy to paste a log, a .env line, a customer email or a stack trace containing a phone number into a prompt without noticing. Once sent, that data is part of the conversation. This mod works as a last safety net: it runs locally, inside Claude Code, on the exact text that is about to be submitted, and rewrites it before it enters the session.

It needs no network, no external service and no configuration.


Install

At the prompt of a Claude Code terminal session:

/plugin install redact-secrets --marketplace webdiego/redact-secrets

What you will see:

  1. Add marketplace? naming github:webdiego/redact-secrets: answer y.
  2. The plugin's details and a choice of scope: pick user (applies to every session) with Enter.
  3. A screen asking for the two options: which countries to cover and the highlight color. Press Enter on each to keep the defaults (all, purple).
  4. Installed redact-secrets. Plugin is now active. The mod runs immediately, no restart needed.

From a shell:

claude plugin install redact-secrets --marketplace webdiego/redact-secrets

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with webdiego/redact-secrets, then Add plugin.

To remove it, from a shell:

claude plugin uninstall redact-secrets

Configuration

At install time Claude Code asks for two options. Both can be changed later from /config, and the mod reloads with the new values right away.

The options screen shown at install

OptionTypeDefaultWhat it does
countriestextallWhich countries' national ID numbers are masked: all, or ISO 3166 codes separated by commas, spaces or semicolons, in any case (IT, US, GB). Unknown codes are ignored.
colorpickerpurpleBackground of the masked values in the transcript.

Countries

Emails, phone numbers, API keys, credit cards and IBANs are always masked, for every country: their formats are universal. The countries option adds the national ID formats below.

CodeCountryDocumentExample
IT🇮🇹 ItalyCodice fiscaleRSSMRA85T10A562S
US🇺🇸 United StatesSocial Security Number123-45-6789
GB🇬🇧 United KingdomNational Insurance numberAB 12 34 56 C
ES🇪🇸 SpainDNI / NIE12345678Z, X1234567L
FR🇫🇷 FranceNIR (numéro de sécurité sociale)1 85 05 78 006 084 36
BR🇧🇷 BrazilCPF123.456.789-09
IN🇮🇳 IndiaPANABCDE1234F
MX🇲🇽 MexicoCURPGODE561231HDFRRN09
CN🇨🇳 ChinaResident identity card11010519491231002X

An ID made only of digits (a US SSN, a French NIR, a Chinese ID) is long enough to still be caught as a number when its country is switched off; it is then labelled phone or card instead of id. IDs with letters (codice fiscale, PAN, CURP, …) are masked only when their country is on.

Colors

The eight highlight colors

ValueHex
purple (default)#bb9af7
blue#7aa2f7
cyan#7dcfff
green#9ece6a
yellow#e0af68
orange#ff9e64
red#f7768e
pink#ff79c6

The palette is Tokyo Night's; the text on the chip is always dark (#1a1b26) for contrast.

Setting the options by hand

Options live in ~/.claude/settings.json under pluginConfigs:

{
  "pluginConfigs": {
    "redact-secrets": {
      "options": { "countries": "IT, ES", "color": "green" }
    }
  }
}

How it works

The mod is a single hooks module, hooks/register.tsx. Four hooks do the work: prompt.submit, tool.call (output masking, and a guard on Edit, Write and Bash) and ui.render on UserMessage; three more draw the banner (session.start, command.run, ui.render on AbovePrompt). When it loads, it reads its two options once: countries becomes the list of national ID patterns to apply, color the chip's background.

1. prompt.submit: masking

Fires after you press Enter and before the prompt enters the session.

  1. Slash commands (/compact, /review ...) are passed through untouched.
  2. The text goes through redact(), which applies the chosen national ID patterns first, then each universal pattern in a fixed order (see below).
  3. The new placeholders are counted by kind. Placeholders you typed yourself are subtracted, so they are never counted twice.
  4. If at least one value was masked, a toast is raised in your locale.
  5. The rewritten text is handed on with next({ ...e, text }). From here on, the engine, the model, the stored transcript and any other plugin see only the masked version.
  6. Fail closed. If the hook ever throws before handing the prompt on, its .catch drops the prompt with the message "redact-secrets could not check this prompt, so it was not sent": an unchecked prompt is never sent unmasked.

2. tool.call: masking what tools return

Fires around every tool call (Read, Bash, Grep, WebFetch, MCP tools, …), after the tool has run and before the model reads its result.

  1. Errors and refused calls are passed through untouched.
  2. Every string in the result goes through redactDeep(), which runs redact() on each one. Encoded images and PDFs (base64, MCP's data) are skipped, so they stay intact.
  3. If nothing changed, the tool's own result is handed on as is. Otherwise the masked result is, and the same toast as for prompts is raised.
  4. Fail closed. If masking throws, the output is withheld from the model: the tool has run, but its unchecked output is never read.

3. tool.call on Edit, Write and Bash: the placeholder guard

Once a .env has been read masked, Claude sees API_KEY=⟦key:sk-•••⟧. Written back, that placeholder would replace the real key on disk. So an Edit whose new_string, or a Write whose content, holds more placeholders than the text it replaces (old_string, or the current file) is refused, and the model is told to leave those lines alone or ask you. Edits that keep placeholders as they were, like this README's own examples, pass.

A Bash command can write too (sed -i, echo … > .env), and what it touches cannot be known in advance, so any command carrying a placeholder is refused.

4. ui.render on UserMessage: styling

Fires when the transcript draws one of your messages.

  • If the message contains no ⟦…⟧ placeholder, the hook calls next(e) and the engine draws the row as usual.
  • Otherwise it draws the row itself: a dim > prefix, the plain text, and every placeholder replaced by a highlighted 🔒 abc••• chip.
          you press Enter
                 │
                 ▼
   ┌───────────────────────────┐
   │ prompt.submit (this mod)  │  redact() → count → toast
   └─────────────┬─────────────┘
                 │ masked text only
                 ▼
   ┌───────────────────────────┐
   │ Claude Code engine        │  stores message, sends to model
   └─────────────┬─────────────┘
                 │
                 ▼
   ┌───────────────────────────┐
   │ ui.render UserMessage     │  draws ⟦…⟧ as purple chips
   └───────────────────────────┘

What is detected

Patterns run in this order. The order matters: keys and secrets come first, so no ID pattern takes a piece of a key; national IDs and IBANs come before phones and cards, so their digits are not later taken as a number.

#KindWhat matchesExamples
0keyPrivate key blocks -----BEGIN … PRIVATE KEY----- … -----END … PRIVATE KEY----- (whole block); Bearer tokens; Basic credentials (base64)Bearer abc.def… → Bearer ⟦key:abc•••⟧
0secretThe password in scheme://user:password@host; the value after a name ending in password, passwd, pass (as a whole word: DB_PASS, not bypass), pwd, secret, secret_key, token, api_key, access_key, private_key and = or : (not env lookups like process.env.X, ${X})PASSWORD=hunter2 → PASSWORD=⟦secret:hun•••⟧
0idThe national ID formats of the countries chosen in the countries optionRSSMRA85T10A562S → ⟦id:RSS•••⟧
1keyOpenAI / Anthropic sk-…, sk-ant-…; Stripe sk_live_…, rk_test_…; GitHub ghp_, gho_, ghu_, ghs_, ghr_, github_pat_…; GitLab glpat-…; SendGrid SG.….…; AWS AKIA…, ASIA…; Google AIza…; Slack xoxb-, xoxp-, xoxe-, …; JWTs eyJ….….…; any 32+ character token that mixes letters and digitssk-ant-api03-… → ⟦key:sk-•••⟧
2emailAny local@domain.tld, Unicode letters includedjosé@müller.de → ⟦email:jos•••⟧
3bank_accountIBAN: 2 letters + 2 digits + 3–7 groups of 4, spaces optional (all countries)DE89 3704 0044 0532 0130 00 → ⟦bank_account:DE8•••⟧
4card13–19 digits, spaces or dashes allowed, passing the Luhn check (others fall to phone)4111 1111 1111 1111 → ⟦card:411•••⟧
5phoneOptional + or 00 prefix, optional (, then digits with spaces, (), ., /, -, at least 8 digits in total+39 333 123 4567, (555) 123-4567, 020 7946 0958, +49 30/1234567

International digits. Phone and card patterns use Unicode decimal digits (\p{Nd}), so Arabic-Indic (٠١٢٣), Persian (۰۱۲۳), Devanagari (०१२३) and other native numerals are detected like Latin ones.

The 8-digit floor. For phone, card and bank_account, a match with fewer than 8 digits is left alone. This keeps years (2024), prices, versions, ports and short counts out of the mask.

Not phones. Dates (2026-10-23 12:30, 2024.10.26, 23/10/2026), IPv4 addresses (192.168.1.20) and build numbers with a one-digit group inside (130.0.6723.91) are left alone, so pasted logs stay readable.


The placeholder format

⟦<kind>:<first 3 characters>•••⟧
  • ⟦ and ⟧ (U+27E6 / U+27E7) are rare enough to never collide with normal prose or code.
  • <kind> tells the model what was there (email, phone, key, secret, card, bank_account, id), so it can still reason about the prompt ("send the invoice to ⟦email:mar•••⟧").
  • The 3 visible characters let you recognise which value was masked when several are in the same prompt.

Transcript styling

Each placeholder is drawn as:

<Text color="#1a1b26" backgroundColor={background} bold>{` 🔒 ${prefix}••• `}</Text>

background is the hex of the color option, purple by default. Terminals without true-color support fall back to the nearest available color.


The banner in the CLI

The first time Claude Code starts with the mod installed, a band above the prompt introduces it and shows the active settings:

 ⟦•••⟧  redact-secrets · countries: all · color: purple                    [ Got it ]
        Emails, phones, API keys, passwords, cards, IBANs and national IDs are masked before they are sent.

The ⟦•••⟧ mark is drawn in your highlight color. Got it dismisses it for good (remembered across sessions). Type /redact-secrets at any time to bring it back and print the current settings.

Logo

<img src="docs/logo.svg" alt="redact-secrets logo" width="120">

The logo is the placeholder itself: the ⟦ ⟧ brackets around the ••• that replace a secret, in the Tokyo Night purple-to-blue. docs/logo.svg is the source; .claude-plugin/icon.png (1024×1024) is the plugin's icon.


Toast and languages

The toast reads 🔒 <label>: <icon> <count> · <icon> <count>, one icon per kind: 📧 email, 📞 phone, 🔑 key, 🔐 secret, 💳 card, 🏦 bank_account, 🪪 id. Icons and the neutral label: count form need no translation and avoid plural rules, which differ widely between languages (Polish, Russian, Arabic, …).

The language comes from the environment, in POSIX order: LC_ALL, then LC_MESSAGES, then LANG. Only the language part is used (it_IT.UTF-8 → it, pt-BR → pt). Unknown or unset falls back to English.

CodeLanguageCodeLanguageCodeLanguage
enEnglishcsCzechbnBengali
itItalianroRomanianurUrdu
esSpanishhuHungarianzhChinese
ptPortugueseelGreekjaJapanese
frFrenchruRussiankoKorean
deGermanukUkrainianidIndonesian
nlDutchtrTurkishmsMalay
svSwedisharArabicviVietnamese
daDanishheHebrewthThai
no / nbNorwegianfaPersiantlFilipino
fiFinnishhiHindiswSwahili
plPolish

Translations were written by the author, not reviewed by native speakers. Corrections are welcome.


Project structure

redact-secrets/
├── .claude-plugin/
│   ├── plugin.json          # mod manifest: name, version, options (userConfig), icon
│   ├── icon.png             # 1024×1024 plugin icon
│   └── marketplace.json     # makes this repo installable with one line
├── hooks/
│   ├── hooks.json           # points the engine at register.tsx
│   ├── register.tsx         # the mod: patterns, redact(), countryIds(), toastText(), hooks
│   ├── register.test.ts     # unit tests for the pure functions
│   └── hooks.test.tsx       # integration tests through the real hook chain
├── docs/                    # logo, banner and README illustrations (SVG)
├── types/
│   └── index.d.ts           # state contract: the banner's isBannerShown flag
├── tsconfig.json            # extends the types the engine writes on load
├── LICENSE
└── README.md

register.tsx exports pure functions and tables so they can be tested on their own:

  • redact(text: string, ids?: RegExp[]): string: masks text; ids defaults to every country
  • countryIds(countries: string): RegExp[]: turns the countries option into patterns
  • redactDeep(value: unknown, ids?: RegExp[]): unknown: masks every string in a tool result but encoded files
  • maskedKinds(before: string, after: string): Record<string, number>: the new placeholders, by kind
  • toastText(locale: string | undefined, kinds: Record<string, number>): string
  • NATIONAL_IDS, COLORS: the country and color tables

Tests

Run them with:

claude plugin test .

The runner executes every *.test.ts / *.test.tsx in an environment like the one the mod's hooks run in. Tests import their kit from claude-code/testing. Nothing sits beneath the mod in a test: small hooks registered with on(...) stand in for the engine (answering env.get, capturing ui.toast, echoing prompt.submit).

Current result:

hooks/hooks.test.tsx:
(pass) a prompt reaches the engine masked, with a counted toast in the locale
(pass) a prompt with nothing sensitive passes unchanged and raises no toast
(pass) what a tool prints reaches the model masked
(pass) output that cannot be checked is withheld, never passed on unmasked
(pass) a write whose target cannot be read is refused before it runs
(pass) a placeholder is never written over a real value
(pass) a shell command carrying a placeholder is refused
(pass) a masked value is drawn highlighted in the transcript, on every surface
(pass) a message with nothing masked is left to the engine
(pass) the countries option limits which national IDs are masked
(pass) the color option paints the highlight
(pass) the banner greets on the first session and stays away once dismissed
(pass) /redact-secrets brings the banner back with the settings

hooks/register.test.ts:
(pass) masks secrets to their first 3 characters
(pass) masks passwords, credentials in URLs and private keys
(pass) dates, times and IP addresses are not phone numbers
(pass) the toast speaks the locale, English otherwise, with one icon per kind
(pass) counts only the placeholders that are new, by kind
(pass) commit hashes, digests and UUIDs are not keys
(pass) masks every string of a tool result but encoded files
(pass) masks national ID numbers
(pass) only the chosen countries mask their national IDs

 22 pass
 0 fail

hooks/register.test.ts: unit tests

masks secrets to their first 3 characters checks redact() input by input:

InputExpectedChecks
mail mario.rossi@gmail.com okmail ⟦email:mar•••⟧ okplain email, surrounding text kept
mail josé@müller.demail ⟦email:jos•••⟧Unicode email
tel +39 333 123 4567tel ⟦phone:+39•••⟧Italian mobile, + prefix
call (555) 123-4567call ⟦phone:(55•••⟧US format with parentheses
ring 020 7946 0958ring ⟦phone:020•••⟧UK landline
+49 30/1234567⟦phone:+49•••⟧German format with /
هاتف ٠١٢٣٤٥٦٧٨٩هاتف ⟦phone:٠١٢•••⟧Arabic-Indic digits
key sk-ant-api03-abc…xyzkey ⟦key:sk-•••⟧Anthropic API key
carta 4111 1111 1111 1111carta ⟦card:411•••⟧card number with spaces
ts 1729958400000ts ⟦phone:172•••⟧fails Luhn: not a card, still masked
IT60 X054 2811 1010 0000 0123 456⟦bank_account:IT6•••⟧Italian IBAN
DE89 3704 0044 0532 0130 00⟦bank_account:DE8•••⟧German IBAN
ho 3 gatti e 2024 anni(unchanged)short numbers are not masked

masks passwords, credentials in URLs and private keys checks the secret kind and the newer key formats:

InputExpectedChecks
PASSWORD=hunter2isgoodPASSWORD=⟦secret:hun•••⟧.env assignment
{"api_key": "abc123xyz"}{"api_key": "⟦secret:abc•••⟧"}JSON, quotes kept
GITHUB_TOKEN: x9y8z7w6GITHUB_TOKEN: ⟦secret:x9y•••⟧YAML style
postgres://admin:REDACTED@localhost:5432/apppostgres://admin:REDACTED@localhost:5432/apppassword in a URL, user and host kept
stripe REDACTED-STRIPE-KEY-BY-SLOPSHOPPERstripe ⟦key:sk_•••⟧Stripe key
Authorization: Bearer abc.def-ghi_jkl0123456Authorization: Bearer ⟦key:abc•••⟧Bearer token
Authorization: Basic YWRtaW46…Authorization: Basic ⟦key:YWR•••⟧Basic credentials
uses Basic Authentication(unchanged)prose, not base64
glpat-abcdefghij…⟦key:glp•••⟧GitLab token, whole
SG.abcdefghijklmnopqrstuv.abcd…⟦key:SG.•••⟧SendGrid key, both parts
DB_PASS=hunter2DB_PASS=⟦secret:hun•••⟧pass as a name
bypass: everything(unchanged)pass inside a word

| `REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER

Source 2 files
hooks/register.tsx 308 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4// National ID numbers, switched on per country by the `countries` option.
5export const NATIONAL_IDS: Record<string, RegExp> = {
6  IT: /\b[A-Z]{6}\d{2}[A-EHLMPR-T]\d{2}[A-Z]\d{3}[A-Z]\b/gi, // codice fiscale
7  US: /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
8  GB: /\b[A-CEGHJ-PR-TW-Z]{2} ?\d{2} ?\d{2} ?\d{2} ?[A-D]\b/g, // National Insurance number
9  ES: /\b(?:\d{8}|[XYZ]\d{7})[A-Z]\b/g, // DNI / NIE
10  FR: /\b[12] ?\d{2} ?\d{2} ?(?:\d{2}|2[AB]) ?\d{3} ?\d{3} ?\d{2}\b/g, // NIR (sécurité sociale)
11  BR: /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/g, // CPF
12  IN: /\b[A-Z]{5}\d{4}[A-Z]\b/g, // PAN (Aadhaar's 12 digits are caught as a number)
13  MX: /\b[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d\b/g, // CURP
14  CN: /\b\d{17}[\dX]\b/g, // resident identity card
15}
16
17// Run before national IDs, so no ID pattern takes a piece of a key.
18const SECRETS: [string, RegExp][] = [
19  ['key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
20  ['key', /(?<=\bBearer\s+)[A-Za-z0-9._~+\/-]{16,}=*/g],
21  // A digit, +, / or = sets base64 credentials apart from prose like "Basic Authentication".
22  ['key', /(?<=\bBasic\s+)(?=[A-Za-z0-9+\/]*[\d+\/=])[A-Za-z0-9+\/]{8,}=*/g],
23  ['key', /\b(?:sk-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA)[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|xox[abposre]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|[A-Za-z0-9_-]*(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,})\b/g],
24  // The password in scheme://user:password@host.
25  ['secret', /(?<=:\/\/[^\s:@\/]*:)[^\s@\/]+(?=@)/g],
26  // The value of PASSWORD=…, "api_key": "…", client_secret: …; not env lookups like process.env.X or ${X}.
27  // (?<!⟦): the `secret:` label of a placeholder is not an assignment.
28  // (?<![a-z])pass: DB_PASS, not bypass or compass.
29  ['secret', /(?<=(?<!⟦)(?:passw(?:or)?d|(?<![a-z])pass\b|pwd|secret(?:[_-]?key)?|token|api[_-]?key|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*["']?)(?![$%{(<⟦]|[A-Za-z_]\w*[.([])[^\s"'`,;]{4,}/gi],
30]
31
32// Dates, times, IPv4 addresses and build numbers look like phone numbers but are not secret.
33// A build has a one-digit group inside (130.0.6723.91); dotted phones do not (06.12.34.56.78, 1.800.555.1234).
34const NOT_A_PHONE = /^(?:\d{4}([./-])\d{2}\1\d{2}|\d{1,2}([./-])\d{1,2}\2\d{4})(?: \d{1,2})?$|^\d{1,3}(?:\.\d{1,3}){3}$|^\d+(?:\.\d+)*\.\d(?:\.\d+)+$/
35
36// Git commit hashes, SHA-256 digests and UUIDs fill tool output and are not secret.
37const NOT_A_KEY = /^(?:[\da-f]{40}|[\da-f]{64}|[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})$/i
38
39// Order matters: IBANs before phones, so their digits are not taken as a number.
40const PATTERNS: [string, RegExp][] = [
41  // \p{L}/\p{Nd}: international addresses (josé@müller.de) and non-Latin digits (Arabic, Persian, Devanagari).
42  ['email', /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+\.\p{L}{2,}/gu],
43  ['bank_account', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g],
44  ['card', /(?<!\p{Nd})(?:\p{Nd}[ -]?){13,19}(?!\p{Nd})/gu],
45  ['phone', /(?:\+|(?<!\p{Nd})00)?\(?\p{Nd}[\p{Nd} ().\/-]{6,}\p{Nd}(?!\p{Nd})/gu],
46]
47
48// Every card number passes the Luhn check; most timestamps and order numbers do not.
49const luhn = (digits: number[]) =>
50  digits.reverse().reduce((sum, d, i) => sum + (i % 2 ? (d * 2 > 9 ? d * 2 - 9 : d * 2) : d), 0) % 10 === 0
51
52// What the model and the transcript see in place of a secret: its first 3 characters.
53const MASK = /⟦(\w+):([^⟧]*)•••⟧/g
54
55// "all", or a list like "IT, us,GB": the countries whose national IDs are masked.
56export const countryIds = (countries: string) =>
57  countries.trim().toLowerCase() === 'all'
58    ? Object.values(NATIONAL_IDS)
59    : countries.toUpperCase().split(/[\s,;]+/).flatMap(c => NATIONAL_IDS[c] ?? [])
60
61export const redact = (text: string, ids: RegExp[] = Object.values(NATIONAL_IDS)) =>
62  [...SECRETS, ...ids.map(re => ['id', re] as [string, RegExp]), ...PATTERNS].reduce(
63    (t, [kind, re]) =>
64      t.replace(re, m =>
65        // Fewer than 8 digits is a year, a price, a count: left alone.
66        ((kind === 'card' || kind === 'phone' || kind === 'bank_account') && (m.match(/\p{Nd}/gu) ?? []).length < 8) ||
67        // A long number that fails Luhn is left for the phone pattern, which still masks it.
68        (kind === 'card' && !luhn((m.match(/\p{Nd}/gu) ?? []).map(d => +d))) ||
69        (kind === 'phone' && NOT_A_PHONE.test(m)) ||
70        (kind === 'key' && NOT_A_KEY.test(m))
71          ? m
72          : `⟦${kind}:${m.slice(0, 3)}•••⟧`,
73      ),
74    text,
75  )
76
77// Every string in a tool's result, but encoded images and PDFs (`base64`, MCP's `data`).
78export const redactDeep = (value: unknown, ids?: RegExp[]): unknown =>
79  typeof value === 'string'
80    ? redact(value, ids)
81    : Array.isArray(value)
82      ? value.map(v => redactDeep(v, ids))
83      : value && typeof value === 'object'
84        ? Object.fromEntries(
85            Object.entries(value).map(([k, v]) => [k, k === 'base64' || k === 'data' ? v : redactDeep(v, ids)]),
86          )
87        : value
88
89const placeholders = (text: string) => (text.match(MASK) ?? []).length
90
91// The placeholders `after` holds beyond those already in `before`, by kind, in order of appearance.
92export const maskedKinds = (before: string, after: string) => {
93  const counts: Record<string, number> = {}
94  for (const [, kind = ''] of after.matchAll(MASK)) counts[kind] = (counts[kind] ?? 0) + 1
95  for (const [, kind = ''] of before.matchAll(MASK)) counts[kind] = (counts[kind] ?? 0) - 1
96  return Object.fromEntries(Object.entries(counts).filter(([, n]) => n > 0))
97}
98
99// Tokyo Night palette; the `color` option picks one.
100export const COLORS: Record<string, string> = {
101  purple: '#bb9af7',
102  blue: '#7aa2f7',
103  cyan: '#7dcfff',
104  green: '#9ece6a',
105  yellow: '#e0af68',
106  orange: '#ff9e64',
107  red: '#f7768e',
108  pink: '#ff79c6',
109}
110
111// One neutral "label: n" form per language, so no plural rules are needed.
112const LABELS: Record<string, string> = {
113  en: 'Sensitive data masked before sending',
114  it: 'Dati sensibili oscurati prima dell\'invio',
115  es: 'Datos sensibles ocultados antes de enviar',
116  pt: 'Dados sensíveis ocultados antes do envio',
117  fr: 'Données sensibles masquées avant l\'envoi',
118  de: 'Sensible Daten vor dem Senden maskiert',
119  nl: 'Gevoelige gegevens gemaskeerd vóór verzending',
120  sv: 'Känsliga uppgifter maskerade före sändning',
121  da: 'Følsomme data maskeret før afsendelse',
122  no: 'Sensitive data maskert før sending',
123  nb: 'Sensitive data maskert før sending',
124  fi: 'Arkaluonteiset tiedot peitetty ennen lähetystä',
125  pl: 'Dane wrażliwe ukryte przed wysłaniem',
126  cs: 'Citlivé údaje skryty před odesláním',
127  ro: 'Date sensibile mascate înainte de trimitere',
128  hu: 'Érzékeny adatok elrejtve küldés előtt',
129  el: 'Ευαίσθητα δεδομένα αποκρύφθηκαν πριν την αποστολή',
130  ru: 'Конфиденциальные данные скрыты перед отправкой',
131  uk: 'Конфіденційні дані приховано перед надсиланням',
132  tr: 'Hassas veriler gönderilmeden önce gizlendi',
133  ar: 'تم إخفاء البيانات الحساسة قبل الإرسال',
134  he: 'מידע רגיש הוסתר לפני השליחה',
135  fa: 'داده‌های حساس پیش از ارسال پنهان شدند',
136  hi: 'भेजने से पहले संवेदनशील डेटा छिपाया गया',
137  bn: 'পাঠানোর আগে সংবেদনশীল তথ্য লুকানো হয়েছে',
138  ur: 'بھیجنے سے پہلے حساس ڈیٹا چھپا دیا گیا',
139  zh: '发送前已隐藏敏感数据',
140  ja: '送信前に機密データをマスクしました',
141  ko: '전송 전에 민감한 데이터를 가렸습니다',
142  id: 'Data sensitif disamarkan sebelum dikirim',
143  ms: 'Data sensitif disembunyikan sebelum dihantar',
144  vi: 'Đã ẩn dữ liệu nhạy cảm trước khi gửi',
145  th: 'ซ่อนข้อมูลที่ละเอียดอ่อนก่อนส่งแล้ว',
146  tl: 'Itinago ang sensitibong data bago ipadala',
147  sw: 'Data nyeti imefichwa kabla ya kutuma',
148}
149
150// One icon per kind, so the breakdown needs no translation.
151const ICONS: Record<string, string> = {
152  email: '📧',
153  phone: '📞',
154  key: '🔑',
155  secret: '🔐',
156  card: '💳',
157  bank_account: '🏦',
158  id: '🪪',
159}
160
161// "it_IT.UTF-8" / "pt-BR" -> "it" / "pt"; English when unknown.
162export const toastText = (locale: string | undefined, kinds: Record<string, number>) =>
163  `🔒 ${LABELS[(locale ?? '').toLowerCase().split(/[_.@-]/)[0] ?? ''] ?? LABELS.en}: ${Object.entries(kinds)
164    .map(([kind, n]) => `${ICONS[kind] ?? kind} ${n}`)
165    .join(' · ')}`
166
167// A toast naming what `after` masked beyond `before`; none when nothing was.
168const toast = async ($: EngineInterface, before: string, after: string) => {
169  const kinds = maskedKinds(before, after)
170  if (Object.keys(kinds).length === 0) return
171  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
172  $.ui.toast(toastText(locale, kinds))
173}
174
175const isBannerShown = atom({ plugin: 'redact-secrets', key: 'isBannerShown' } as const, false)
176
177export const register: Register = (on, options) => {
178  const countries = String(options.countries ?? 'all')
179  const color = String(options.color ?? 'purple')
180  const ids = countryIds(countries)
181  const background = COLORS[color] ?? COLORS.purple
182
183  on('session.start', async ($, e, next) => {
184    await $.command.register({ name: 'redact-secrets', description: 'Show the redact-secrets banner and its settings' })
185    // The banner greets once, on the first session after install.
186    if (!(await $.store.get('welcomed'))) await update($, isBannerShown, () => true)
187    return next(e)
188  })
189
190  on('command.run', { command: 'redact-secrets' }, async $ => {
191    await update($, isBannerShown, () => true)
192    return { text: `redact-secrets: countries ${countries}, color ${color}` }
193  })
194
195  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196    if (e.props.hasSurvey || !(await read($, isBannerShown))) return next(e)
197
198    const { Box, Button, Text } = $.ui.resolve(e)
199    return (
200      <Box flexDirection="row" gap={1}>
201        <Text color="#1a1b26" backgroundColor={background} bold>
202          {' ⟦•••⟧ '}
203        </Text>
204        <Box flexDirection="column">
205          <Text>
206            <Text bold>redact-secrets</Text>
207            <Text dimColor> · countries: {countries} · color: {color}</Text>
208          </Text>
209          <Text dimColor>Emails, phones, API keys, passwords, cards, IBANs and national IDs are masked before they are sent.</Text>
210        </Box>
211        <Button
212          key="dismiss"
213          label="Got it"
214          onPress={async () => {
215            await $.store.set('welcomed', true)
216            await update($, isBannerShown, () => false)
217          }}
218        />
219      </Box>
220    )
221  })
222
223  on('prompt.submit', async ($, e, next) => {
224    if (e.command) return next(e)
225    const text = redact(e.text, ids)
226    await toast($, e.text, text)
227    return next({ ...e, text })
228  }).catch(($, e, next) =>
229    // Fail closed: a prompt that could not be checked is not sent unmasked.
230    next.called ? next(e) : { drop: 'redact-secrets could not check this prompt, so it was not sent' },
231  )
232
233  // What a tool read or printed (a file, a command's output, a web page) reaches the model masked too.
234  on('tool.call', async ($, e, next) => {
235    const ran = await next(e)
236    if (ran.deny !== undefined || ran.isError) return ran
237    const before = JSON.stringify(ran.result) ?? ''
238    const result = redactDeep(ran.result, ids)
239    const after = JSON.stringify(result) ?? ''
240    if (after === before) return ran
241    await toast($, before, after)
242    return { result, context: ran.context }
243  }).catch(() =>
244    // Fail closed: the tool has run, but output that could not be checked is withheld.
245    ({ deny: 'redact-secrets could not check this output, so it was withheld' }),
246  )
247
248  // A placeholder is not the value it hides: writing one into a file would replace a real secret.
249  const guard = (path: string, before: string, after: string) =>
250    placeholders(after) > placeholders(before)
251      ? {
252          deny:
253            `redact-secrets: this would write a masked placeholder (⟦…•••⟧) into ${path}, ` +
254            'destroying the real value it stands for. Leave those lines untouched, or ask the user to edit them.',
255        }
256      : undefined
257
258  on('tool.call', { tool: 'Edit' }, ($, e, next) => guard(e.file_path, e.old_string, e.new_string) ?? next(e)).catch(
259    ($, e, next) => (next.called ? next(e) : { deny: 'redact-secrets could not check this edit' }),
260  )
261
262  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
263    // A file that exists but cannot be read (permissions, over 4 MiB) cannot be checked: refused.
264    const current = (await $.fs.exists(e.file_path)) ? await $.fs.read(e.file_path).catch(() => undefined) : ''
265    if (current === undefined) return { deny: 'redact-secrets could not check this write' }
266    return guard(e.file_path, current, e.content) ?? next(e)
267  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'redact-secrets could not check this write' }))
268
269  // A shell command can write too (sed -i, echo > .env), and what it touches cannot be known: any placeholder is refused.
270  on('tool.call', { tool: 'Bash' }, ($, e, next) =>
271    placeholders(e.command) > 0
272      ? {
273          deny:
274            'redact-secrets: this command carries a masked placeholder (⟦…•••⟧), which could be written over the real value it ' +
275            'stands for. Refer to the value through its file or an environment variable, or ask the user to run the command.',
276        }
277      : next(e),
278  ).catch(($, e, next) => (next.called ? next(e) : { deny: 'redact-secrets could not check this command' }))
279
280  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
281    const text = e.props.text
282    if (!text.includes('⟦')) return next(e)
283
284    const { Box, Text } = $.ui.resolve(e)
285    const parts: RenderChildren[] = []
286    let last = 0
287    for (const m of text.matchAll(MASK)) {
288      parts.push(text.slice(last, m.index))
289      parts.push(
290        <Text color="#1a1b26" backgroundColor={background} bold>
291          {` 🔒 ${m[2]}••• `}
292        </Text>,
293      )
294      last = m.index! + m[0].length
295    }
296    parts.push(text.slice(last))
297
298    return (
299      <Box>
300        <Text>
301          <Text dimColor>{'> '}</Text>
302          {parts}
303        </Text>
304      </Box>
305    )
306  })
307}
308
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'redact-secrets': { isBannerShown: boolean }
4  }
5}
6