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

<img src="docs/banner.svg" alt="redact-secrets: mask sensitive data before it leaves your prompt" width="100%">
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.
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.
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.
At the prompt of a Claude Code terminal session:
/plugin install redact-secrets --marketplace webdiego/redact-secrets
What you will see:
Add marketplace? naming github:webdiego/redact-secrets: answer y.all, purple).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
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.
| Option | Type | Default | What it does |
|---|---|---|---|
countries | text | all | Which 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. |
color | picker | purple | Background of the masked values in the transcript. |
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.
| Code | Country | Document | Example |
|---|---|---|---|
IT | 🇮🇹 Italy | Codice fiscale | RSSMRA85T10A562S |
US | 🇺🇸 United States | Social Security Number | 123-45-6789 |
GB | 🇬🇧 United Kingdom | National Insurance number | AB 12 34 56 C |
ES | 🇪🇸 Spain | DNI / NIE | 12345678Z, X1234567L |
FR | 🇫🇷 France | NIR (numéro de sécurité sociale) | 1 85 05 78 006 084 36 |
BR | 🇧🇷 Brazil | CPF | 123.456.789-09 |
IN | 🇮🇳 India | PAN | ABCDE1234F |
MX | 🇲🇽 Mexico | CURP | GODE561231HDFRRN09 |
CN | 🇨🇳 China | Resident identity card | 11010519491231002X |
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.
| Value | Hex |
|---|---|
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.
Options live in ~/.claude/settings.json under pluginConfigs:
{
"pluginConfigs": {
"redact-secrets": {
"options": { "countries": "IT, ES", "color": "green" }
}
}
}
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.
prompt.submit: maskingFires after you press Enter and before the prompt enters the session.
/compact, /review ...) are passed through untouched.redact(), which applies the chosen national ID patterns first, then each universal pattern in a fixed order (see below).next({ ...e, text }). From here on, the engine, the model, the stored transcript and any other plugin see only the masked version..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.tool.call: masking what tools returnFires around every tool call (Read, Bash, Grep, WebFetch, MCP tools, …), after the tool has run and before the model reads its result.
redactDeep(), which runs redact() on each one. Encoded images and PDFs (base64, MCP's data) are skipped, so they stay intact.tool.call on Edit, Write and Bash: the placeholder guardOnce 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.
ui.render on UserMessage: stylingFires when the transcript draws one of your messages.
⟦…⟧ placeholder, the hook calls next(e) and the engine draws the row as usual.> 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
└───────────────────────────┘
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.
| # | Kind | What matches | Examples |
|---|---|---|---|
| 0 | key | Private key blocks -----BEGIN … PRIVATE KEY----- … -----END … PRIVATE KEY----- (whole block); Bearer tokens; Basic credentials (base64) | Bearer abc.def… → Bearer ⟦key:abc•••⟧ |
| 0 | secret | The 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•••⟧ |
| 0 | id | The national ID formats of the countries chosen in the countries option | RSSMRA85T10A562S → ⟦id:RSS•••⟧ |
| 1 | key | OpenAI / 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 digits | sk-ant-api03-… → ⟦key:sk-•••⟧ |
| 2 | email | Any local@domain.tld, Unicode letters included | josé@müller.de → ⟦email:jos•••⟧ |
| 3 | bank_account | IBAN: 2 letters + 2 digits + 3–7 groups of 4, spaces optional (all countries) | DE89 3704 0044 0532 0130 00 → ⟦bank_account:DE8•••⟧ |
| 4 | card | 13–19 digits, spaces or dashes allowed, passing the Luhn check (others fall to phone) | 4111 1111 1111 1111 → ⟦card:411•••⟧ |
| 5 | phone | Optional + 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.
⟦<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•••⟧").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 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.
<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.
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.
| Code | Language | Code | Language | Code | Language |
|---|---|---|---|---|---|
en | English | cs | Czech | bn | Bengali |
it | Italian | ro | Romanian | ur | Urdu |
es | Spanish | hu | Hungarian | zh | Chinese |
pt | Portuguese | el | Greek | ja | Japanese |
fr | French | ru | Russian | ko | Korean |
de | German | uk | Ukrainian | id | Indonesian |
nl | Dutch | tr | Turkish | ms | Malay |
sv | Swedish | ar | Arabic | vi | Vietnamese |
da | Danish | he | Hebrew | th | Thai |
no / nb | Norwegian | fa | Persian | tl | Filipino |
fi | Finnish | hi | Hindi | sw | Swahili |
pl | Polish |
Translations were written by the author, not reviewed by native speakers. Corrections are welcome.
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 countrycountryIds(countries: string): RegExp[]: turns the countries option into patternsredactDeep(value: unknown, ids?: RegExp[]): unknown: masks every string in a tool result but encoded filesmaskedKinds(before: string, after: string): Record<string, number>: the new placeholders, by kindtoastText(locale: string | undefined, kinds: Record<string, number>): stringNATIONAL_IDS, COLORS: the country and color tablesRun 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 testsmasks secrets to their first 3 characters checks redact() input by input:
| Input | Expected | Checks |
|---|---|---|
mail mario.rossi@gmail.com ok | mail ⟦email:mar•••⟧ ok | plain email, surrounding text kept |
mail josé@müller.de | mail ⟦email:jos•••⟧ | Unicode email |
tel +39 333 123 4567 | tel ⟦phone:+39•••⟧ | Italian mobile, + prefix |
call (555) 123-4567 | call ⟦phone:(55•••⟧ | US format with parentheses |
ring 020 7946 0958 | ring ⟦phone:020•••⟧ | UK landline |
+49 30/1234567 | ⟦phone:+49•••⟧ | German format with / |
هاتف ٠١٢٣٤٥٦٧٨٩ | هاتف ⟦phone:٠١٢•••⟧ | Arabic-Indic digits |
key sk-ant-api03-abc…xyz | key ⟦key:sk-•••⟧ | Anthropic API key |
carta 4111 1111 1111 1111 | carta ⟦card:411•••⟧ | card number with spaces |
ts 1729958400000 | ts ⟦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:
| Input | Expected | Checks |
|---|---|---|
PASSWORD=hunter2isgood | PASSWORD=⟦secret:hun•••⟧ | .env assignment |
{"api_key": "abc123xyz"} | {"api_key": "⟦secret:abc•••⟧"} | JSON, quotes kept |
GITHUB_TOKEN: x9y8z7w6 | GITHUB_TOKEN: ⟦secret:x9y•••⟧ | YAML style |
postgres://admin:REDACTED@localhost:5432/app | postgres://admin:REDACTED@localhost:5432/app | password in a URL, user and host kept |
stripe REDACTED-STRIPE-KEY-BY-SLOPSHOPPER | stripe ⟦key:sk_•••⟧ | Stripe key |
Authorization: Bearer abc.def-ghi_jkl0123456 | Authorization: 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=hunter2 | DB_PASS=⟦secret:hun•••⟧ | pass as a name |
bypass: everything | (unchanged) | pass inside a word |
| `REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER
hooks/register.tsx 308 lines1import { 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}
308types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'redact-secrets': { isBannerShown: boolean }
4 }
5}
6