SLOPSHOPPER

redact-restore

Reversible redaction for Claude Code: private values are replaced with placeholders before the model API sees them and restored locally. No API gateway.

newpanerowsguardcommandtoast
v0.2.0AGPL-3.0-onlyupdated 2026-10-15isandrel/redact-restore
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact-restore
│ ┃ Redact Restore ✕ › fix the failing auth test and add an audit log call │ ┃ [ Rules ] [ Scan ] [ Session ] [ Test ] [ Sh │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ Add a rule ⏺ Update(src/auth.ts) │ ┃ │ Rule type: Exact value ▾ ⎿ Added 2 lines, removed 1 line │ ┃ │ Private value: e.g. Acme Corp ⏎ save ⏺ Bash(bun test) │ ┃ │ Replace with: Placeholder with a label ▾ ⎿ 3 pass, 1 fail │ ┃ │ Label: CUSTOM ▾ │ ┃ │ Description for the model (optional): what ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Ignore case: Follow the option (no) ▾ │ ┃ │ The model sees: {{PRIVATE:CUSTOM#<id>}} ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ Add rule ] [ Clear ] │ ┃ ╰─────────────────────────────────────────── › /redact-restore │ ┃ ⎿ redact-restore: Redact Restore rules pane opened. │ ┃ Saved rules (0) · plugin store, takes effect │ ┃ now │ ┃ None yet. Add one above, or use Scan. │ ┃ │ ┃ Saved patterns (0) · plugin store, takes │ ┃ effect now │ ┃ None yet. Pick Pattern as the rule type to │ ┃ add one. │ ┃ │ ┃ Plugin options (0) · keychain, edit in │ ┃ /plugin ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ redact-restore: 2 private values masked

Draws

Pane · Redact Restore
[ Rules ] [ Scan ] [ Session ] [ Test ] [ Show values ] [ Cl ╭─────────────────────────────────────────────────────────── │ Add a rule │ Rule type: Exact value ▾ │ Private value: e.g. Acme Corp ⏎ save │ Replace with: Placeholder with a label ▾ │ Label: CUSTOM ▾ │ Description for the model (optional): what it is, not the │ Ignore case: Follow the option (no) ▾ │ The model sees: {{PRIVATE:CUSTOM#<id>}} │ [ Add rule ] [ Clear ] ╰─────────────────────────────────────────────────────────── Saved rules (0) · plugin store, takes effect now None yet. Add one above, or use Scan. Saved patterns (0) · plugin store, takes effect now None yet. Pick Pattern as the rule type to add one. Plugin options (0) · keychain, edit in /plugin
README

🔐 Redact Restore

Redacted for the model. Restored for you.

Use Claude Code on private projects without handing your secrets to the model.<br> Private values become placeholders before they leave your machine, and come back the moment they return.

CI License: AGPL-3.0 Claude Code Mod Status: experimental

/plugin install redact-restore --marketplace isandrel/redact-restore

🧪 Try it in 2 minutes with a fake email


✨ Why Redact Restore

Every prompt you type, every file Claude reads and every command output goes to the model. Customer names, internal hostnames, API keys, the email in your git config: all of it is context.

Redact Restore sits inside Claude Code as a native Mod:

  • 🛡️ The model sees placeholders, such as {{PRIVATE:EMAIL#3fa1c09e8b2d}}, never the value.
  • 🔁 You see the real thing. Messages render with the original values on your screen.
  • 🧰 Your tools still work. Bash, Edit, Write, Grep and local MCP tools receive the real values, so builds, scripts and file edits are correct.
  • 🔑 Nothing changes about your login. Official Claude Pro/Max OAuth, no API gateway, no proxy, no custom base URL.
  • 💸 Free and open source (AGPL-3.0). No account, service or fee of its own: you only need the Claude Code you already use, on whatever Claude plan runs it.

What it does not do. It is experimental, not a data-loss-prevention product. It hides only values it knows: your rules plus the built-in detectors. Images and PDFs are withheld, not scanned. Local tools and other installed mods see real values by design. Details: security model.

🧪 Try It in 2 Minutes

No setup and no real data: email detection is on by default, so one fake address shows the whole round trip.

1. Install at a Claude Code prompt, then start a new session:

/plugin install redact-restore --marketplace isandrel/redact-restore

2. Send this prompt:

My test email is alice@example.com. Spell it back one character at a time, then run: echo alice@example.com > redact-test.txt

3. Check the result:

WhereYou should seeWhich proves
The reply on your screenalice@example.comPlaceholders are restored locally
The spelled-out characters{ { P R I V A T E : E M A I L # …The model only ever had the placeholder
cat redact-test.txt in your own terminalalice@example.comLocal tools receive the original
/redact-restore sessionOne masked value of kind EMAIL, with its placeholderWhat was masked this session

4. Tell us what happened. Share what you saw in each row, and anything masked that should not be, through the bug report or false positive forms. Use fake data only.

🧭 How It Works

sequenceDiagram
    autonumber
    actor You
    participant RR as Redact Restore<br/>(on your machine)
    participant Model as Claude model API
    participant Tools as Local tools

    You->>RR: Email alice@example.com about Project Phoenix
    RR->>Model: Email {{PRIVATE:EMAIL#35;3fa1…}} about {{PRIVATE:CUSTOM#35;9c2e…}}
    Model-->>RR: Run a command with {{PRIVATE:EMAIL#35;3fa1…}}
    RR->>Tools: Command runs with alice@example.com
    Tools-->>RR: Output (may contain originals)
    RR->>Model: Output masked again
    Model-->>RR: Reply with placeholders
    RR-->>You: Reply shown with the original values

Everything that leaves is masked, then audited

flowchart LR
    subgraph Local["🖥️ Your machine"]
        P[Your prompts]
        C[CLAUDE.md and account email]
        A[Mentioned files and reminders]
        S[System prompt]
        R[Tool results]
        M{{"Mask<br/>your values · email · 240+ detectors"}}
        AU{"Pre-send audit"}
        P --> M
        C --> M
        A --> M
        S --> M
        R --> M
        M --> AU
    end
    AU -->|placeholders only| API[("Claude API")]
    AU -.->|value still visible| X["⛔ Request refused"]

Local tools get real values; model-bound tools never do

flowchart TD
    Call["Tool call from the model"] --> Kind{"Where does its input go?"}
    Kind -->|"To another model or service:<br/>Agent, WebFetch, WebSearch, Workflow, …"| Keep["Placeholders kept"]
    Kind -->|"Stays on this machine:<br/>Bash, Edit, Write, Grep, local MCP"| Mode{"restore_mode"}
    Mode -->|"all (default)"| Real["Original values restored"]
    Mode -->|"files"| Files["Only file edits restored"]
    Mode -->|"none"| Refuse["Placeholders kept; file edits refused"]

🚀 Features

FeatureWhat it does
🎯 Your own valuesList names, codenames, hosts or any exact string. In a form: pick a label (PROJECT → {{PRIVATE:PROJECT#…}}) or set a fixed alias (CODENAME_X). Add patterns for formats such as ACME-[A-Z0-9]{16}, checked locally before they run.
🔎 240+ detectors200+ API key and token formats (Anthropic, OpenAI, AWS, GitHub, Stripe, Google, …, including the gitleaks rule set), JWTs, private keys, passwords, KEY=value settings, internal host names, IP and MAC addresses, cards, IBANs, and Chinese ID, mobile and company codes, with checksum and entropy validation.
🧱 Defense in depthMasks prompts, context, attachments, the system prompt and every stored row; audits each request before it is sent; withholds images it cannot scan.
🔁 Reversible by designSame value, same placeholder within a session (or in every session with placeholder_ids: stable), kept across /resume. Restored on screen (messages, tool rows, while streaming) and in local tool inputs. Unknown placeholders are refused, never guessed.
🏷️ Described, not revealedThe model learns what a value is (client company name, the value of DB_PASSWORD) without seeing it, so it can still reason about the task.
⚙️ ConfigurablePlaceholder format, which tools get originals, which detectors run, status line content: all options, all with safe defaults.
🗂️ Rules pane and scan/redact-restore rules to add, update or remove values; /redact-restore scan [PATH] to find your name, coworkers, hosts and settings values locally, on this machine or in any folder; import and export to move them between machines. Values are shown only on your screen, masked until you ask.
📊 VisibleA status line such as 4 private values masked, a Session tab with this session's counts and placeholders, a template, and a status file your own statusLine script can read (counts only, never values).
🧪 TestedUnit and integration suites through the engine's own dispatch, live tests in a real Claude Code session, and a regression test for every reviewed leak.

🆚 Compared With

CapabilityAPI gateway or proxyOne-way redaction hooksRedact Restore
Keeps the official Claude login and endpoint❌✅✅
Model never sees configured values✅✅✅
Shows you the original values✅❌✅
Local commands and file edits get real values✅⚠️ some✅
Model-bound tools kept on placeholders➖➖✅
Checks every request before it is sent⚠️ varies❌✅

📦 Quick Start

1. Install from isandrel/redact-restore. At a Claude Code prompt, in the terminal or the desktop app's Code tab (answer y to add the marketplace, then pick a scope):

/plugin install redact-restore --marketplace isandrel/redact-restore

Or from a shell:

claude plugin marketplace add isandrel/redact-restore
claude plugin install redact-restore@redact-restore

2. Use Claude as usual. Paste logs, configs or snippets with tokens and secrets: emails, API keys, passwords, private keys, KEY=value settings, card, phone and ID numbers are masked before they leave and restored on your screen.

3. Add what detectors cannot know, such as names, customers and codenames. Unsure about a piece of text? /redact-restore test shows what the model would receive, on your machine, before you send it:

  • /redact-restore scan finds your git identity, coworkers, remotes, machine and account names, and .env values; /redact-restore scan PATH checks any folder or file. It runs on your machine, so nothing is sent to the model to judge. Pick Add or Ignore in the pane.
  • /redact-restore rules adds your own in a form: the private value (Acme Corp), then a label (CUSTOMER) or a fixed alias (CLIENT_A), with a preview of what the model sees.
  • For your most sensitive values, use the keychain instead: /plugin → Installed → redact-restore → Configure options → Exact private values.

4. Start clean and check. Run /clear, then /redact-restore session or look at the status line under the prompt. Ask Claude to spell a protected value character by character: it can only spell the placeholder.

Update later with claude plugin update redact-restore@redact-restore. The plugin also ships skills you can ask for by name: /redact-restore:protect, :scan, :status, :policy, :placeholders, :data, :troubleshoot, :import and :export.

Requires Claude Code 2.1.287 or newer with Mods support (the desktop app's Code tab from 2.1.286). A user-scope install is active in every CLI and Desktop Code session; --scope local limits it to one folder.

📚 Documentation

DocumentWhat it covers
ConfigurationEvery option, placeholder formats, labels and aliases, status fields
DetectorsWhat is detected, how it is validated, how to turn detectors on or off
Security modelCoverage of every path to the model, review findings, known limitations
CompatibilityCLI and Desktop Code test matrix
Test planAutomated and live test steps
ChangelogRelease notes

⚠️ Status

Redact Restore is experimental. It is tested live in the Claude Code CLI (2.1.287, and 2.1.292 for display restore, descriptions, /resume and the prompt cache), including subagents, compaction and concurrent sessions. In the desktop app's Code tab the status line is confirmed; the rest is kit-tested only so far. It is not a data-loss-prevention product: it protects the paths described in the security model, and tools that run locally act on real values by design. Try it with synthetic data first.

🤝 Contributing

Contributions are welcome: new detectors, false-positive fixes, docs and tests. Read the contributing guide, and report leaks privately through SECURITY.md, never in a public issue. This project follows a Code of Conduct.

🌐 Community

Thanks to LINUX DO for supporting open-source developers and community sharing.

📄 License

GNU Affero General Public License v3.0. The vendored gitleaks rules in hooks/vendor/ are MIT-licensed; see hooks/vendor/LICENSE-gitleaks.txt.

Source 18 files
hooks/register.ts 1468 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register, TurnStepChunk, TurnStepResult } from 'claude-code';
3import { parseConfig, type RedactRestoreConfig } from './config.ts';
4import {
5  COMMAND,
6  COMMAND_HINT,
7  EXPORT_FILE,
8  MESSAGES,
9  PANE_ID,
10  PANE_TITLE,
11  SCAN_LOG_AUTHORS,
12  DISPLAY_RESTORE_FIELDS,
13  SESSION_MAP_DAYS,
14  SESSION_MAP_DIR,
15  LABEL_DESCRIPTIONS,
16  LEGEND_MAX_ENTRIES,
17  ID_KEY_STORE_KEY,
18  type DisplayRow,
19  SCAN_MAX_DEPTH,
20  SCAN_MAX_DIRS,
21  SCAN_MAX_FILE_BYTES,
22  SCAN_TIMEOUT_MS,
23} from './defaults.ts';
24import { normalizeRules, type MatchingOptions } from './matcher.ts';
25import {
26  draftFrom,
27  draftPattern,
28  draftProblem,
29  drawPane,
30  formLabels,
31  EMPTY_DRAFT,
32  EMPTY_VIEW,
33  sessionView,
34  type PaneView,
35  type RuleDraft,
36  type ScanView,
37  type SessionView,
38} from './pane.tsx';
39import type { ValueRule } from './placeholder.ts';
40import {
41  applyImport,
42  applyPatternImport,
43  describeProblem,
44  displayPath,
45  expandHome,
46  exportRules,
47  IGNORED_PREFIX,
48  isRuleKey,
49  makeRule,
50  maskPreview,
51  mergedRules,
52  parseImport,
53  isPatternKey,
54  makePattern,
55  parseStoredPatterns,
56  parseStoredRules,
57  PATTERN_PREFIX,
58  patternsKey,
59  RULE_PREFIX,
60  ruleKey,
61  rulesKey,
62  sortRules,
63  storeKeyFor,
64  upsertRule,
65  type StoredPattern,
66  type StoredRule,
67  upsertPattern,
68} from './rules.ts';
69import { describePatternProblem, patternDetector, patternKey } from './patterns.ts';
70import {
71  assignedValues,
72  collectCandidates,
73  countByLabel,
74  fileCandidates,
75  isEnvFile,
76  maskedKinds,
77  triage,
78  type ScanCandidate,
79} from './scan.ts';
80import { isSessionId, parseSessionMap, type SessionMap, serializeSessionMap } from './session-map.ts';
81import { formatStatus, statusFileContent, statusStats } from './status.ts';
82import { combine, emptyMetrics, metricsChange, type Metrics, type MetricsChange } from './metrics.ts';
83import { CUSTOM_LABEL, EMAIL_LABEL, isIdKey, newIdKey } from './placeholder.ts';
84import { keptTools, toolAction, type RestorePolicy } from './policy.ts';
85import {
86  auditMessages,
87  createAuditMemo,
88  createMasker,
89  createStreamRestorer,
90  describeFindings,
91  inputHasPlaceholder,
92  maskContentBlocks,
93  maskText,
94  restoreInput,
95  uniqueMatches,
96  restoreFields,
97  legendText,
98  type Notes,
99  unmaskText,
100  withholdContentBlocks,
101  type AuditMemo,
102  type Vault,
103} from './privacy.ts';
104
105// Session-scoped reactive state survives source hot reload. A /clear resets it
106// explicitly (session.end below). Any installed plugin can read $.state, so
107// other mods are inside Redact Restore's trust boundary (docs/SECURITY.md).
108const vault = atom({ plugin: 'redact-restore', key: 'vault' }, {} as Vault);
109const metrics = atom({ plugin: 'redact-restore', key: 'metrics' }, emptyMetrics(0));
110// Rules from the plugin store, mirrored per session so the pane and the hooks
111// read one value; the store is read again on each prompt (other sessions edit
112// it, and /clear or /resume reset $.state without a new session.start).
113const rules = atom({ plugin: 'redact-restore', key: 'rules' }, [] as readonly StoredRule[]);
114const patterns = atom({ plugin: 'redact-restore', key: 'patterns' }, [] as readonly StoredPattern[]);
115const scanResult = atom({ plugin: 'redact-restore', key: 'scan' }, null as ScanView | null);
116const pane = atom({ plugin: 'redact-restore', key: 'pane' }, EMPTY_VIEW);
117interface Legend {
118  readonly notes: Notes;
119  readonly sent: Readonly<Record<string, true>>;
120}
121const EMPTY_LEGEND: Legend = { notes: {}, sent: {} };
122
123// Descriptions of this session's placeholders, and which ones the model has been told.
124const legend = atom({ plugin: 'redact-restore', key: 'legend' }, EMPTY_LEGEND);
125// The session whose saved placeholder map was looked for (see loadSessionMap).
126const mapChecked = atom({ plugin: 'redact-restore', key: 'mapChecked' }, null as string | null);
127
128function unrestoredDenied(tool: string): string {
129  return (
130    `Redact Restore: ${tool} would receive private placeholder text, and restoration is off for it ` +
131    '("restore_mode"). Rewrite the call without placeholders, or change the option.'
132  );
133}
134
135/**
136 * Records new token mappings (the only writer of the vault) and a counter
137 * change, then refreshes the status. Counters and status are informational:
138 * their failure never fails the hook, while a failed vault write does (and the
139 * hook's fallback then masks without recording).
140 */
141async function record(
142  $: EngineInterface,
143  config: RedactRestoreConfig,
144  additions: Vault,
145  change?: MetricsChange,
146  notes: Notes = {},
147): Promise<void> {
148  if (Object.keys(notes).length > 0)
149    await update($, legend, current => ({ ...current, notes: { ...current.notes, ...notes } }));
150  const stored =
151    Object.keys(additions).length > 0 ? await update($, vault, current => ({ ...current, ...additions })) : undefined;
152  if (stored) lastVault = stored;
153  if (stored || Object.keys(notes).length > 0) {
154    try {
155      await saveSessionMap($);
156    } catch {
157      // The map is for /resume only: a failed write must not block masking.
158    }
159  }
160  if (!change && !stored) return;
161  try {
162    const counters = change ? await update($, metrics, current => change(current, Date.now())) : await read($, metrics);
163    await publishStatus($, stored ?? (await vaultNow($)), counters, config);
164  } catch {
165    // Reporting must not block masking.
166  }
167}
168
169/** Counts a hook failure from its fallback; never throws. */
170async function recordHookError($: EngineInterface, config: RedactRestoreConfig, hook: string): Promise<void> {
171  try {
172    await record($, config, {}, metricsChange.hookError(hook));
173  } catch {
174    // A fallback's own $ calls may be refused (re-entry); the count is best effort.
175  }
176}
177
178/** Settings Redact Restore had to ignore: short values, bad rules, unknown detectors, a bad format. */
179function configProblems(config: RedactRestoreConfig): number {
180  return (
181    config.ignoredShortValues + config.invalidPatterns + config.unknownDetectors.length + (config.formatProblem ? 1 : 0)
182  );
183}
184
185/**
186 * Shows the status line and, when asked, writes the counts a status-line
187 * script reads.
188 */
189async function publishStatus(
190  $: EngineInterface,
191  current: Vault,
192  counters: Metrics,
193  config: RedactRestoreConfig,
194): Promise<void> {
195  const stats = statusStats(
196    current,
197    {
198      mode: config.restore.mode,
199      audit: config.outboundAudit,
200      streaming: config.restoreStreaming,
201      configProblems: configProblems(config),
202    },
203    counters,
204    config.placeholders,
205  );
206  $.ui.status(formatStatus(config.statusLine, config.statusFormat, stats));
207  if (!config.statusFile) return;
208  const home = await homeDir($);
209  if (!home) return;
210  await $.fs.write(
211    `${home}/.claude/redact-restore/status/${await $.session.id()}.json`,
212    statusFileContent(
213      stats,
214      counters,
215      {
216        placeholderFormat: config.placeholders.format,
217        idLength: config.placeholders.idLength,
218        statusLine: config.statusLine,
219        extraKeepTools: config.restore.keepTools.length,
220        ignoredRules: config.ignoredShortValues,
221        formatProblem: config.formatProblem !== undefined,
222      },
223      { model: await $.session.model(), updatedAt: Date.now() },
224    ),
225  );
226}
227
228/** The plugin's version from its manifest, or '' when unreadable. */
229async function pluginVersion($: EngineInterface): Promise<string> {
230  try {
231    const manifest: unknown = JSON.parse(String(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)));
232    return manifest && typeof manifest === 'object' && 'version' in manifest ? String(manifest.version) : '';
233  } catch {
234    return '';
235  }
236}
237
238/** What the top-level helpers need from `register`: they may take `$`, closures may not. */
239interface Context {
240  readonly config: RedactRestoreConfig;
241  /** Rules from the plugin options (keychain), normalized. */
242  readonly optionRules: readonly ValueRule[];
243  /** The local key for stable placeholder ids, once loaded (`placeholder_ids: stable`). */
244  readonly idKey: { value?: string };
245}
246
247/**
248 * The last vault this module read, for fallbacks (which may not call $): known
249 * values then keep their placeholders, so a failure does not change text the
250 * prompt cache has already seen.
251 */
252let lastVault: Vault = {};
253
254async function vaultNow($: EngineInterface): Promise<Vault> {
255  let current = await read($, vault);
256  if (sessionMapSettings.persist && isEmpty(current)) {
257    try {
258      current = (await loadSessionMap($)) ?? current;
259    } catch {
260      // The saved map only helps after /resume; masking goes on without it.
261    }
262  }
263  lastVault = current;
264  return current;
265}
266
267function isEmpty(record: object): boolean {
268  for (const _ in record) return false;
269  return true;
270}
271
272// Session maps: each session's placeholder map in a local file, so /resume (which
273// starts $.state empty) restores what earlier turns masked. Set from the options.
274let sessionMapSettings: RedactRestoreConfig['sessionMap'] = { persist: false, days: SESSION_MAP_DAYS };
275let mapSaving: Promise<void> | undefined;
276let mapDirty = false;
277const mapPathsReady = new Set<string>();
278
279async function sessionMapDir($: EngineInterface): Promise<string | undefined> {
280  const home = await homeDir($);
281  return home ? `${home.replace(/[\\/]+$/, '')}/${SESSION_MAP_DIR}` : undefined;
282}
283
284async function sessionMapPath($: EngineInterface): Promise<string | undefined> {
285  const dir = await sessionMapDir($);
286  const id = await $.session.id();
287  return dir && isSessionId(id) ? `${dir}/${id}.json` : undefined;
288}
289
290/** Brings back the session's saved map once, after a resume or a restart. */
291async function loadSessionMap($: EngineInterface): Promise<Vault | undefined> {
292  // Kept in $.state, which a resume empties: each resumed session looks once.
293  const id = await $.session.id();
294  if ((await read($, mapChecked)) === id) return undefined;
295  await update($, mapChecked, () => id);
296  const path = await sessionMapPath($);
297  if (!path) return undefined;
298  let saved: SessionMap | undefined;
299  try {
300    saved = parseSessionMap(String(await $.fs.read(path)));
301  } catch {
302    return undefined; // No saved map: a new session.
303  }
304  if (!saved || isEmpty(saved.vault)) return undefined;
305  const { vault: savedVault, notes, sent } = saved;
306  const merged = await update($, vault, current => ({ ...savedVault, ...current }));
307  await update($, legend, current => ({
308    notes: { ...notes, ...current.notes },
309    sent: { ...sent, ...current.sent },
310  }));
311  return merged;
312}
313
314/** A tool result with a note added after its content, as the model reads it. */
315function withNote<T extends { readonly type: string }>(block: T, note: string): T {
316  const content = (block as { readonly content?: unknown }).content;
317  if (typeof content === 'string') return { ...block, content: `${content}\n\n${note}` };
318  if (Array.isArray(content)) return { ...block, content: [...content, { type: 'text', text: note }] };
319  return { ...block, content: note };
320}
321
322/** Described placeholders the model has not been told about yet, oldest first. */
323async function untoldNotes($: EngineInterface, notes: Notes, additions: Vault): Promise<Array<[string, string]>> {
324  const told = await read($, legend);
325  const known = { ...(await vaultNow($)), ...additions };
326  return Object.entries({ ...told.notes, ...notes })
327    .filter(([token]) => known[token] !== undefined && !told.sent[token])
328    .slice(0, LEGEND_MAX_ENTRIES);
329}
330
331async function markTold($: EngineInterface, entries: ReadonlyArray<readonly [string, string]>): Promise<void> {
332  if (entries.length === 0) return;
333  await update($, legend, current => ({
334    ...current,
335    sent: { ...current.sent, ...Object.fromEntries(entries.map(([token]) => [token, true] as const)) },
336  }));
337  await saveSessionMap($).catch(ignore);
338}
339
340/**
341 * The vault for drawing. A render may not write $.state, so after a resume the
342 * rows drawn before the next event read the saved map without loading it.
343 */
344let drawnMap: { readonly id: string; readonly vault: Vault } | undefined;
345async function displayVault($: EngineInterface): Promise<Vault> {
346  const current = await read($, vault);
347  if (!sessionMapSettings.persist || !isEmpty(current)) return current;
348  try {
349    const id = await $.session.id();
350    if (drawnMap?.id !== id) {
351      // Remembered per session, found or not: a missing file is not read again on every draw.
352      drawnMap = { id, vault: {} };
353      const path = await sessionMapPath($);
354      const saved = path ? parseSessionMap(String(await $.fs.read(path).catch(() => ''))) : undefined;
355      if (saved) drawnMap = { id, vault: saved.vault };
356    }
357    return drawnMap.vault;
358  } catch {
359    return current;
360  }
361}
362
363/** Writes the session's map; saves that arrive while one runs are folded into one more. */
364async function saveSessionMap($: EngineInterface): Promise<void> {
365  if (!sessionMapSettings.persist) return;
366  if (mapSaving) {
367    mapDirty = true;
368    return mapSaving;
369  }
370  mapSaving = (async () => {
371    do {
372      mapDirty = false;
373      await writeSessionMap($);
374    } while (mapDirty);
375  })().finally(() => {
376    mapSaving = undefined;
377  });
378  return mapSaving;
379}
380
381async function writeSessionMap($: EngineInterface): Promise<void> {
382  const path = await sessionMapPath($);
383  const current = await read($, vault);
384  if (!path || isEmpty(current)) return;
385  const told = await read($, legend);
386  // Owner-only folder and file where the system has chmod; elsewhere the home folder's own access applies.
387  if (!mapPathsReady.has(path)) await runQuietly($, ['mkdir', '-p', '-m', '700', path.slice(0, path.lastIndexOf('/'))]);
388  await $.fs.write(path, serializeSessionMap({ vault: current, notes: told.notes, sent: told.sent }, new Date()));
389  if (!mapPathsReady.has(path)) {
390    await runQuietly($, ['chmod', '600', path]);
391    mapPathsReady.add(path);
392  }
393}
394
395/** Deletes maps of sessions untouched for `session_map_days`. */
396async function pruneSessionMaps($: EngineInterface): Promise<void> {
397  const dir = await sessionMapDir($);
398  if (!dir) return;
399  let entries: readonly { name: string; kind: string; mtimeMs: number }[];
400  try {
401    entries = await $.fs.list(dir);
402  } catch {
403    return;
404  }
405  const cutoff = Date.now() - sessionMapSettings.days * 86_400_000;
406  const old = entries
407    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.mtimeMs < cutoff)
408    .map(entry => `${dir}/${entry.name}`);
409  if (old.length > 0) await runQuietly($, ['rm', '-f', '--', ...old]);
410}
411
412// Text the audit found clean, per matching options: new rules or detectors start a fresh memo.
413const auditMemos = new WeakMap<MatchingOptions, AuditMemo>();
414function auditMemoFor(matching: MatchingOptions): AuditMemo {
415  let memo = auditMemos.get(matching);
416  if (!memo) {
417    memo = createAuditMemo();
418    auditMemos.set(matching, memo);
419  }
420  return memo;
421}
422
423/** The home folder on macOS, Linux (HOME) and Windows (USERPROFILE). */
424async function homeDir($: EngineInterface): Promise<string | undefined> {
425  return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined;
426}
427
428// Options plus stored rules; rebuilt only when either changes.
429const mergedCache = new WeakMap<RedactRestoreConfig, { key: string; options: MatchingOptions }>();
430/** The last merged rules, for fallbacks after a reload (a superset is safe: it only masks more). */
431let lastMerged: MatchingOptions | undefined;
432
433function matchingFor(
434  ctx: Context,
435  stored: readonly StoredRule[],
436  storedPatterns: readonly StoredPattern[] = [],
437): MatchingOptions {
438  const { matching } = ctx.config;
439  if (stored.length === 0 && storedPatterns.length === 0) return matching;
440  const key = `${rulesKey(stored)}\u0000${patternsKey(storedPatterns)}`;
441  const cached = mergedCache.get(ctx.config);
442  if (cached?.key === key) return cached.options;
443  const normalized = normalizeRules(mergedRules(ctx.optionRules, stored));
444  const options = {
445    ...matching,
446    customValues: normalized.values,
447    rules: normalized.rules,
448    detectors: [
449      ...storedPatterns.map((rule, index) => patternDetector(rule, `pattern_${index}`)),
450      ...(matching.detectors ?? []),
451    ],
452  };
453  mergedCache.set(ctx.config, { key, options });
454  lastMerged = options;
455  return options;
456}
457
458async function currentMatching($: EngineInterface, ctx: Context): Promise<MatchingOptions> {
459  await ensureIdKey($, ctx);
460  return matchingFor(ctx, await read($, rules), await read($, patterns));
461}
462
463/**
464 * Loads the key for stable ids from the plugin store, or makes one. Until it
465 * loads (or if the store fails), placeholders are random: never less private.
466 */
467async function ensureIdKey($: EngineInterface, ctx: Context): Promise<void> {
468  if (ctx.config.placeholderIds !== 'stable' || ctx.idKey.value !== undefined) return;
469  try {
470    let key = await $.store.get(ID_KEY_STORE_KEY);
471    if (!isIdKey(key)) {
472      await $.store.set(ID_KEY_STORE_KEY, newIdKey());
473      // Read back: two sessions starting together adopt the key written last.
474      key = await $.store.get(ID_KEY_STORE_KEY);
475    }
476    if (isIdKey(key)) ctx.idKey.value = key;
477  } catch {
478    // Random ids for now; the next masking tries again.
479  }
480}
481
482/** For fallbacks, which may not call $: the last merged rules, a superset of the options. */
483function fallbackMatching(ctx: Context): MatchingOptions {
484  return mergedCache.get(ctx.config)?.options ?? lastMerged ?? ctx.config.matching;
485}
486
487/**
488 * Reads the stored rules (one store key per rule, so concurrent sessions never
489 * overwrite each other's edits) into the session. Keeps the session's copy when
490 * the store is unreadable.
491 */
492async function loadRules($: EngineInterface, strict = false): Promise<readonly StoredRule[]> {
493  const current = await read($, rules);
494  let stored: StoredRule[];
495  let storedPatterns: StoredPattern[];
496  try {
497    const keys = await $.store.keys();
498    const ruleKeys = keys.filter(isRuleKey);
499    const patternKeys = keys.filter(isPatternKey);
500    const [rawRules, rawPatterns] = await Promise.all([
501      Promise.all(ruleKeys.map(key => $.store.get(key))),
502      Promise.all(patternKeys.map(key => $.store.get(key))),
503    ]);
504    stored = sortRules(parseStoredRules(rawRules));
505    storedPatterns = parseStoredPatterns(rawPatterns);
506  } catch (error) {
507    // Prompts must not go out without the saved rules (prompt.submit is strict);
508    // the pane and commands work on the session's copy.
509    if (strict) throw error;
510    return current;
511  }
512  if (rulesKey(stored) !== rulesKey(current)) await update($, rules, () => stored);
513  if (patternsKey(storedPatterns) !== patternsKey(await read($, patterns))) {
514    await update($, patterns, () => storedPatterns);
515  }
516  return stored;
517}
518
519async function putPattern($: EngineInterface, pattern: StoredPattern): Promise<void> {
520  await $.store.set(await storeKeyFor(PATTERN_PREFIX, patternKey(pattern)), pattern);
521}
522
523async function deletePattern($: EngineInterface, pattern: StoredPattern): Promise<void> {
524  await $.store.delete(await storeKeyFor(PATTERN_PREFIX, patternKey(pattern)));
525  await loadRules($);
526  await setNotice($, 'Pattern removed. This session keeps masking what it already masked until /clear.');
527}
528
529/** The rule as the list now holds it (an update keeps its added time). */
530function savedRule(list: readonly StoredRule[], rule: StoredRule): StoredRule {
531  return list.find(one => ruleKey(one.value) === ruleKey(rule.value)) ?? rule;
532}
533
534async function putRule($: EngineInterface, rule: StoredRule): Promise<void> {
535  await $.store.set(await storeKeyFor(RULE_PREFIX, rule.value), rule);
536}
537
538async function setNotice($: EngineInterface, notice: string, change: Partial<PaneView> = {}): Promise<void> {
539  await update($, pane, current => ({ ...current, ...change, notice }));
540}
541
542/** Runs a command for the scan; an error or a missing program reads as no output. */
543async function runQuietly($: EngineInterface, argv: readonly string[]): Promise<string> {
544  try {
545    const result = await $.process.run(argv, { timeoutMs: SCAN_TIMEOUT_MS });
546    return result.exitCode === 0 ? result.stdout : '';
547  } catch {
548    return '';
549  }
550}
551
552/**
553 * Saves the pane's rule form. `patch` carries a field's latest text when Enter
554 * submitted it (its last change may not be in the draft yet). While editing,
555 * the value is fixed: only the label or alias changes.
556 */
557async function saveDraft($: EngineInterface, ctx: Context, patch: Partial<RuleDraft> = {}): Promise<void> {
558  const draft: RuleDraft = { ...EMPTY_DRAFT, ...(await read($, pane)).draft, ...patch };
559  const problem = draftProblem(draft);
560  if (problem) {
561    await setNotice($, describeProblem(problem), { draft });
562    return;
563  }
564  if (draft.kind === 'pattern') {
565    await savePatternDraft($, ctx, draft);
566    return;
567  }
568  const stored = await loadRules($);
569  const value = draft.value.trim();
570  const label = draft.label.trim().toUpperCase();
571  const ignoreCase = draft.ignoreCase === 'default' ? {} : { ignoreCase: draft.ignoreCase === 'yes' };
572  const description = draft.description.trim() ? { description: draft.description.trim() } : {};
573  const parsed = makeRule(
574    draft.mode === 'alias'
575      ? { value, alias: draft.alias.trim(), ...ignoreCase, ...description }
576      : { value, ...(label !== CUSTOM_LABEL ? { label } : {}), ...ignoreCase, ...description },
577    'manual',
578    new Date(),
579    [...ctx.optionRules, ...stored],
580  );
581  if (!('rule' in parsed)) {
582    await setNotice($, describeProblem(parsed.problem), { draft });
583    return;
584  }
585  const result = upsertRule(stored, parsed.rule);
586  if (result.change !== 'unchanged') await putRule($, savedRule(result.rules, parsed.rule));
587  await loadRules($);
588  const notice =
589    result.change === 'unchanged'
590      ? `${maskPreview(parsed.rule.value)} is already saved like this.`
591      : `${maskPreview(parsed.rule.value)} ${result.change}.${MESSAGES.alreadySent}`;
592  await setNotice($, notice, { draft: EMPTY_DRAFT });
593}
594
595/** Saves a pattern rule from the form: checked (example included) before it is stored. */
596async function savePatternDraft($: EngineInterface, ctx: Context, draft: RuleDraft): Promise<void> {
597  const made = makePattern(draftPattern(draft, ctx.config.matching.customCaseInsensitive), 'manual', new Date());
598  if (!('pattern' in made)) {
599    await setNotice($, describePatternProblem(made.problem), { draft });
600    return;
601  }
602  await loadRules($);
603  const result = upsertPattern(await read($, patterns), made.pattern);
604  const saved = result.patterns.find(one => patternKey(one) === patternKey(made.pattern)) ?? made.pattern;
605  if (result.change !== 'unchanged') await putPattern($, saved);
606  await loadRules($);
607  const notice =
608    result.change === 'unchanged'
609      ? 'That pattern is already saved like this.'
610      : `Pattern ${result.change}.${MESSAGES.alreadySent}`;
611  await setNotice($, notice, { draft: EMPTY_DRAFT });
612}
613
614async function setDraft($: EngineInterface, change: Partial<RuleDraft>): Promise<void> {
615  await update($, pane, current => ({ ...current, draft: { ...EMPTY_DRAFT, ...current.draft, ...change } }));
616}
617
618/** For pane handlers whose failure leaves nothing to undo (a view switch, a draft keystroke). */
619function ignore(): void {}
620
621/**
622 * Checks pasted text as the masking hooks would, with the session's known values
623 * and placeholders, but records nothing: the result stays in the pane.
624 */
625async function testText($: EngineInterface, ctx: Context, text: string): Promise<void> {
626  if (!text.trim()) {
627    await update($, pane, current => ({ ...current, test: undefined, notice: '' }));
628    return;
629  }
630  const matching = matchingFor(ctx, await loadRules($), await read($, patterns));
631  const masked = maskText(text, matching, await vaultNow($));
632  const found = uniqueMatches(text, matching, await vaultNow($));
633  await update($, pane, current => ({ ...current, notice: '', test: { text, masked: masked.text, found } }));
634}
635
636/** Closing the pane drops an unsaved draft and a tested text: both may hold originals. */
637async function closePane($: EngineInterface): Promise<void> {
638  await update($, pane, current => ({ ...current, draft: EMPTY_DRAFT, test: undefined, notice: '' }));
639  await $.ui.close({ id: PANE_ID });
640}
641
642async function deleteRule($: EngineInterface, value: string): Promise<void> {
643  await $.store.delete(await storeKeyFor(RULE_PREFIX, value));
644  await loadRules($);
645  await setNotice($, `${maskPreview(value)} removed. This session keeps masking it until /clear; new sessions do not.`);
646}
647
648/** Looks for candidates on this machine and in the working folder; values stay in the pane's state. */
649async function scanNow($: EngineInterface, ctx: Context): Promise<ScanView> {
650  const home = await homeDir($);
651  let entries: readonly { name: string; kind: string; size: number }[] = [];
652  try {
653    entries = await $.fs.list();
654  } catch {
655    // No readable working folder: scan the machine-level sources only.
656  }
657  const envFiles: Array<{ name: string; text: string }> = [];
658  let packageJson: string | undefined;
659  for (const entry of entries) {
660    if (entry.kind !== 'file' || entry.size > SCAN_MAX_FILE_BYTES) continue;
661    try {
662      if (isEnvFile(entry.name)) envFiles.push({ name: entry.name, text: String(await $.fs.read(entry.name)) });
663      else if (entry.name === 'package.json') packageJson = String(await $.fs.read(entry.name));
664    } catch {
665      // Unreadable files are skipped.
666    }
667  }
668  const found = collectCandidates({
669    gitName: await runQuietly($, ['git', 'config', '--get', 'user.name']),
670    gitEmail: await runQuietly($, ['git', 'config', '--get', 'user.email']),
671    remotes: await runQuietly($, ['git', 'remote', '-v']),
672    logAuthors: await runQuietly($, ['git', 'log', `-n${SCAN_LOG_AUTHORS}`, '--format=%an%x00%ae']),
673    hostname: await runQuietly($, ['hostname']),
674    ...(home ? { home } : {}),
675    envFiles,
676    ...(packageJson ? { packageJson } : {}),
677  });
678  const ignored = await ignoredFingerprints($);
679  const result = await triage(found, matchingFor(ctx, await loadRules($), await read($, patterns)), ignored);
680  await update($, scanResult, () => result);
681  return result;
682}
683
684/**
685 * Reads the text files under a folder (or one file) for a path scan: skipped
686 * folders, binary files and files over the size limit are left out. Every file
687 * looked at counts toward the file limit, read or not. Nothing read here is
688 * sent anywhere; the caller keeps candidates in the pane's state.
689 */
690async function readTextFiles(
691  $: EngineInterface,
692  ctx: Context,
693  root: string,
694): Promise<{ files: Array<{ name: string; text: string }>; skipped: number; partial: boolean }> {
695  const files: Array<{ name: string; text: string }> = [];
696  const { skipDirs, maxFiles } = ctx.config.scan;
697  let seen = 0;
698  let dirs = 0;
699  let partial = false;
700  const addFile = async (path: string, name: string, size: number) => {
701    seen += 1;
702    if (size > SCAN_MAX_FILE_BYTES) return;
703    try {
704      const text = String(await $.fs.read(path));
705      if (!text.slice(0, 1024).includes('\u0000')) files.push({ name, text });
706    } catch {
707      // Unreadable files are skipped.
708    }
709  };
710  const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
711    if (dirs >= SCAN_MAX_DIRS) {
712      partial = true;
713      return;
714    }
715    dirs += 1;
716    let entries: readonly { name: string; kind: string; size: number }[];
717    try {
718      entries = await $.fs.list(dir);
719    } catch {
720      return;
721    }
722    for (const entry of entries) {
723      // Skipped folders and other kinds are never looked at: they do not make a scan partial.
724      if (entry.kind === 'dir' ? skipDirs.includes(entry.name) : entry.kind !== 'file') continue;
725      if (seen >= maxFiles || dirs > SCAN_MAX_DIRS) {
726        partial = true;
727        return;
728      }
729      const path = `${dir}/${entry.name}`;
730      const name = prefix ? `${prefix}/${entry.name}` : entry.name;
731      if (entry.kind === 'dir') {
732        if (depth < SCAN_MAX_DEPTH) await walk(path, name, depth + 1);
733        else partial = true;
734      } else if (entry.kind === 'file') {
735        await addFile(path, name, entry.size);
736      }
737    }
738  };
739  let stat: { kind: string; size: number } | undefined;
740  try {
741    stat = await $.fs.stat(root);
742  } catch {
743    // Missing or unreadable: nothing to read.
744  }
745  if (stat?.kind === 'dir') await walk(root.replace(/[\\/]+$/, '') || root, '', 0);
746  else if (stat?.kind === 'file') await addFile(root, root.split(/[\\/]/).pop() ?? root, stat.size);
747  return { files, skipped: seen - files.length, partial };
748}
749
750/** Scans a folder or file on this machine: new candidates to the pane, counts by label to the caller. */
751async function scanPath($: EngineInterface, ctx: Context, target: string): Promise<ScanView> {
752  const home = await homeDir($);
753  const root = expandHome(target, home);
754  const { files, skipped, partial } = await readTextFiles($, ctx, root);
755  const matching = matchingFor(ctx, await loadRules($), await read($, patterns));
756  const ignored = await ignoredFingerprints($);
757  const envOn = ctx.config.detectorIds.includes('env_assignment');
758  const assigned = assignedValues(files);
759  const result: ScanView = {
760    ...(await triage(fileCandidates(files), matching, ignored, candidate => envOn && assigned.has(candidate.value))),
761    target: displayPath(root, home),
762    files: files.length,
763    ...(skipped ? { skipped } : {}),
764    ...(partial ? { partial } : {}),
765    kinds: maskedKinds(files, matching),
766  };
767  await update($, scanResult, () => result);
768  return result;
769}
770
771/** Scan again what was scanned last: the same folder, or this machine. */
772async function rescan($: EngineInterface, ctx: Context): Promise<void> {
773  const last = await read($, scanResult);
774  if (last?.target) await scanPath($, ctx, expandHome(last.target, await homeDir($)));
775  else await scanNow($, ctx);
776}
777
778/** What a scan tells the model: counts by label, and the path as shown (`~/…`); never a value. */
779function scanSummary(result: ScanView): string {
780  const left = [
781    result.skipped ? `${result.skipped} skipped as binary, too large or unreadable` : '',
782    result.partial ? 'stopped at a file, folder or depth limit' : '',
783  ].filter(Boolean);
784  const where = result.target
785    ? `Scan of ${result.target} (${result.files ?? 0} file(s) read${left.length ? `; ${left.join('; ')}` : ''})`
786    : 'Scan';
787  const masked = result.kinds?.length
788    ? ` Already masked in these files: ${result.kinds.map(([label, count]) => `${count} ${label}`).join(', ')}.`
789    : '';
790  return (
791    `${where} found ${result.candidates.length} new candidate(s)` +
792    (result.candidates.length ? ` (${countByLabel(result.candidates)})` : '') +
793    `; ${result.covered} already masked, ${result.ignored} ignored before.${masked} ` +
794    'Review them in the Redact Restore pane. Values are shown only there, on this machine.'
795  );
796}
797
798async function ignoredFingerprints($: EngineInterface): Promise<Set<string>> {
799  const ignored = new Set<string>();
800  try {
801    for (const key of await $.store.keys())
802      if (key.startsWith(IGNORED_PREFIX)) ignored.add(key.slice(IGNORED_PREFIX.length));
803  } catch {
804    // An unreadable ignore list shows every candidate again.
805  }
806  return ignored;
807}
808
809async function ignoreCandidate($: EngineInterface, candidate: ScanCandidate): Promise<void> {
810  await $.store.set(await storeKeyFor(IGNORED_PREFIX, candidate.value), true);
811  await update($, scanResult, current =>
812    current
813      ? {
814          ...current,
815          candidates: current.candidates.filter(one => one.value !== candidate.value),
816          ignored: current.ignored + 1,
817        }
818      : current,
819  );
820  await setNotice($, `${maskPreview(candidate.value)} ignored; later scans skip it.`);
821}
822
823async function acceptCandidates($: EngineInterface, ctx: Context, accepted: readonly ScanCandidate[]): Promise<void> {
824  let stored = [...(await loadRules($))];
825  const now = new Date();
826  const taken: string[] = [];
827  for (const candidate of accepted) {
828    const existing = [...ctx.optionRules, ...stored];
829    const input = { value: candidate.value, label: candidate.label };
830    let parsed = makeRule(
831      candidate.description ? { ...input, description: candidate.description } : input,
832      'scan',
833      now,
834      existing,
835    );
836    // A suggested description that does not fit is dropped, not the rule.
837    if ('problem' in parsed && parsed.problem.includes('description')) parsed = makeRule(input, 'scan', now, existing);
838    if (!('rule' in parsed)) continue;
839    const result = upsertRule(stored, parsed.rule);
840    stored = result.rules;
841    if (result.change !== 'unchanged') await putRule($, savedRule(result.rules, parsed.rule));
842    taken.push(candidate.value);
843  }
844  await loadRules($);
845  await update($, scanResult, current =>
846    current
847      ? {
848          ...current,
849          candidates: current.candidates.filter(one => !taken.includes(one.value)),
850          covered: current.covered + taken.length,
851        }
852      : current,
853  );
854  await setNotice($, `${taken.length} rule(s) added.${taken.length > 0 ? MESSAGES.alreadySent : ''}`);
855}
856
857async function acceptAll($: EngineInterface, ctx: Context): Promise<void> {
858  await acceptCandidates($, ctx, (await read($, scanResult))?.candidates ?? []);
859}
860
861async function exportTo($: EngineInterface, target: string): Promise<string> {
862  const home = await homeDir($);
863  const path = target ? expandHome(target, home) : home ? `${home}/.claude/redact-restore/${EXPORT_FILE}` : EXPORT_FILE;
864  const stored = await loadRules($);
865  const savedPatterns = await read($, patterns);
866  await $.fs.write(path, exportRules(stored, new Date(), savedPatterns));
867  const withPatterns = savedPatterns.length ? ` and ${savedPatterns.length} pattern(s)` : '';
868  return (
869    `Exported ${stored.length} saved rule(s)${withPatterns} to ${displayPath(path, home)}. Rules in the plugin options ` +
870    '(keychain) are not exported. The file holds the original values in plain text: keep it out of ' +
871    'version control and delete it after importing.'
872  );
873}
874
875async function importFrom($: EngineInterface, ctx: Context, args: string): Promise<string> {
876  const replace = /(?:^|\s)--replace(?:\s|$)/.test(args);
877  const target = args.replace(/(?:^|\s)--replace(?=\s|$)/, '').trim();
878  if (!target) return MESSAGES.usage;
879  const home = await homeDir($);
880  const path = expandHome(target, home);
881  let text: string;
882  try {
883    text = String(await $.fs.read(path));
884  } catch {
885    return `Could not read ${displayPath(path, home)}.`;
886  }
887  const parsed = parseImport(text);
888  if ('error' in parsed) return `Nothing imported: ${parsed.error}`;
889  const before = await loadRules($, true);
890  const beforePatterns = await read($, patterns);
891  const mode = replace ? 'replace' : 'merge';
892  // A file without a patterns list (an older export) leaves the saved patterns alone.
893  const patternMode = replace && parsed.patterns ? 'replace' : 'merge';
894  const summary = applyImport(before, parsed.items, mode, new Date(), ctx.optionRules);
895  const patternSummary = applyPatternImport(beforePatterns, parsed.patterns ?? [], patternMode, new Date());
896  // Replacing drops every saved rule: only from a file whose every entry is valid.
897  const invalid = summary.invalid + patternSummary.invalid;
898  const valid =
899    summary.added +
900    summary.updated +
901    summary.unchanged +
902    patternSummary.added +
903    patternSummary.updated +
904    patternSummary.unchanged;
905  if (replace && (invalid > 0 || valid === 0)) {
906    return (
907      `Nothing imported: --replace needs a file whose entries are all valid (${invalid} invalid, ` +
908      `${valid} valid). Saved rules are unchanged.`
909    );
910  }
911  // Write first, then remove: a failed write leaves the old rules in place.
912  for (const rule of summary.rules) await putRule($, rule);
913  for (const pattern of patternSummary.patterns) await putPattern($, pattern);
914  // Replacing removes every other stored key of that kind, unreadable entries included.
915  const keep = new Set([
916    ...(await Promise.all(summary.rules.map(rule => storeKeyFor(RULE_PREFIX, rule.value)))),
917    ...(await Promise.all(patternSummary.patterns.map(one => storeKeyFor(PATTERN_PREFIX, patternKey(one))))),
918  ]);
919  for (const key of await $.store.keys()) {
920    const replaced = (replace && isRuleKey(key)) || (patternMode === 'replace' && isPatternKey(key));
921    if (replaced && !keep.has(key)) await $.store.delete(key);
922  }
923  const after = await loadRules($);
924  const patternNote = parsed.patterns
925    ? ` Patterns: ${patternSummary.added} added, ${patternSummary.updated} updated, ` +
926      `${patternSummary.unchanged} unchanged, ${patternSummary.invalid} skipped as invalid; ` +
927      `${(await read($, patterns)).length} saved now.`
928    : replace && beforePatterns.length
929      ? ` The file has no patterns list: the ${beforePatterns.length} saved pattern(s) are kept.`
930      : '';
931  return (
932    `Imported from ${displayPath(path, home)} (${replace ? 'replaced all saved rules' : 'merged'}): ` +
933    `${summary.added} added, ${summary.updated} updated, ${summary.unchanged} unchanged, ` +
934    `${summary.invalid} skipped as invalid. ${after.length} saved rule(s) now.${patternNote}`
935  );
936}
937
938async function statusText($: EngineInterface, ctx: Context): Promise<string> {
939  const { config } = ctx;
940  const stored = await loadRules($);
941  const counters = await read($, metrics);
942  const stats = statusStats(
943    await vaultNow($),
944    {
945      mode: config.restore.mode,
946      audit: config.outboundAudit,
947      streaming: config.restoreStreaming,
948      configProblems: configProblems(config),
949    },
950    counters,
951    config.placeholders,
952  );
953  return [
954    `Redact Restore ${counters.version}${config.enabled ? '' : ' (disabled)'}`,
955    `Rules: ${stored.length} saved, ${ctx.optionRules.length} in plugin options; patterns: ` +
956      `${(await read($, patterns)).length} saved, ${config.optionPatterns.length} in plugin options; ` +
957      `${config.detectorIds.length} detectors.`,
958    `This session: ${formatStatus('detailed', '', stats) ?? 'nothing masked yet'}.`,
959    `Replacements: ${stats.occurrences} (${stats.lastTurn} since the last prompt). Tool calls: ${stats.restored} restored, ` +
960      `${stats.kept} kept on placeholders, ${stats.denied} denied. Requests: ${stats.audits} audited, ${stats.blocked} blocked.`,
961    `Restore mode: ${config.restore.mode}; outbound audit ${config.outboundAudit ? 'on' : 'off'}; ` +
962      `streaming restore ${config.restoreStreaming ? 'on' : 'off'}.`,
963  ].join('\n');
964}
965
966async function sessionNow($: EngineInterface, ctx: Context, savedRules: number): Promise<SessionView> {
967  const { config } = ctx;
968  const current = await vaultNow($);
969  const counters = await read($, metrics);
970  const stats = statusStats(
971    current,
972    {
973      mode: config.restore.mode,
974      audit: config.outboundAudit,
975      streaming: config.restoreStreaming,
976      configProblems: configProblems(config),
977    },
978    counters,
979    config.placeholders,
980  );
981  return sessionView(current, counters, stats, config.placeholders, {
982    detectors: config.detectorIds.length,
983    savedRules,
984    optionRules: ctx.optionRules.length,
985  });
986}
987
988async function openPane($: EngineInterface, view: PaneView['view']): Promise<void> {
989  await update($, pane, current => ({ ...current, view, notice: '' }));
990  await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true, closeOnEscape: true });
991}
992
993export const register: Register = (on, options) => {
994  // Sensitive custom_values come from Claude Code's secure userConfig flow.
995  // Never log their originals or insert them into files, prompts, or diagnostics.
996  const idKey: { value?: string } = {};
997  const config = parseConfig(options, () => idKey.value);
998  sessionMapSettings = config.enabled ? config.sessionMap : { ...config.sessionMap, persist: false };
999  const { matching } = config;
1000  const policy = { allowMedia: config.allowMedia };
1001  const ctx: Context = { config, optionRules: [...(matching.rules?.values() ?? [])], idKey };
1002
1003  on('session.start', async ($, e, next) => {
1004    // Also fires after a reload or an option change: redraw with the new settings.
1005    const version = await pluginVersion($);
1006    await update($, metrics, current => ({
1007      ...current,
1008      startedAt: current.startedAt || Date.now(),
1009      version,
1010      surface: e.surface ?? current.surface,
1011    }));
1012    try {
1013      await loadRules($, true);
1014    } catch {
1015      if (config.enabled) $.ui.toast(MESSAGES.rulesUnreadable);
1016    }
1017    if (config.enabled) await record($, config, {}, (m, now) => ({ ...m, updatedAt: now }));
1018    else $.ui.status(undefined);
1019    if (sessionMapSettings.persist && sessionMapSettings.days > 0) await pruneSessionMaps($).catch(ignore);
1020    if (config.enabled && config.ignoredShortValues > 0) {
1021      $.ui.toast(`Redact Restore ignored ${config.ignoredShortValues} custom value(s) shorter than 3 characters.`);
1022    }
1023    if (config.enabled && config.invalidPatterns > 0) {
1024      $.ui.toast(
1025        `Redact Restore skipped ${config.invalidPatterns} private pattern(s) that are invalid or could match slowly; ` +
1026          'check them in /redact-restore rules.',
1027      );
1028    }
1029    if (config.enabled && config.unknownDetectors.length > 0) {
1030      $.ui.toast(`Redact Restore: unknown detector name(s): ${config.unknownDetectors.join(', ')}.`);
1031    }
1032    if (config.enabled && config.formatProblem) {
1033      $.ui.toast(`Redact Restore: placeholder_format ${config.formatProblem}; using the default format.`);
1034    }
1035    // Last, and guarded: a refused registration must not skip the work above.
1036    try {
1037      await $.command.register({ name: COMMAND, description: MESSAGES.commandDescription, argumentHint: COMMAND_HINT });
1038    } catch {
1039      $.ui.log('Redact Restore could not register /redact-restore; the rules pane is unavailable this session.');
1040    }
1041    return next(e);
1042  });
1043
1044  // The vault belongs to one conversation; a /clear starts another.
1045  on('session.end', async ($, e, next) => {
1046    if (e.reason === 'clear') {
1047      await update($, vault, () => ({}));
1048      await update($, legend, () => EMPTY_LEGEND);
1049      lastVault = {};
1050      // The cleared conversation's saved map must not come back into the new one.
1051      const id = await $.session.id().catch(() => null);
1052      await update($, mapChecked, () => id);
1053      if (id) drawnMap = { id, vault: {} };
1054      const previous = await read($, metrics);
1055      await update($, metrics, () => emptyMetrics(Date.now(), previous.version, previous.surface));
1056      await record($, config, {}, (m, now) => ({ ...m, lastEvent: 'clear', updatedAt: now }));
1057    }
1058    return next(e);
1059  });
1060
1061  on('prompt.submit', async ($, e, next) => {
1062    if (!config.enabled) return next(e);
1063    if (e.attachments?.length && !config.allowMedia) {
1064      await record($, config, {}, metricsChange.promptDropped());
1065      return { drop: MESSAGES.unscannablePrompt };
1066    }
1067    let stored: readonly StoredRule[];
1068    try {
1069      stored = await loadRules($, true);
1070    } catch {
1071      await recordHookError($, config, 'rules');
1072      return { drop: MESSAGES.rulesUnreadable };
1073    }
1074    await ensureIdKey($, ctx);
1075    const masker = createMasker(matchingFor(ctx, stored, await read($, patterns)), await vaultNow($));
1076    const text = masker.mask(e.text);
1077    const masked = e.context?.map(item => masker.mask(item)) ?? [];
1078    // Descriptions the model has not had yet go with this prompt, as context the user does not see.
1079    const fresh = await untoldNotes($, masker.notes(), masker.additions());
1080    const context = fresh.length > 0 ? [...masked, legendText(fresh, MESSAGES.legendHeader, masker.mask)] : masked;
1081    await record(
1082      $,
1083      config,
1084      masker.additions(),
1085      combine(metricsChange.newTurn(), metricsChange.masked('prompt', masker.count(), 'prompt.submit')),
1086      masker.notes(),
1087    );
1088    await markTold($, fresh);
1089    return next({ ...e, text, ...(context.length > 0 ? { context } : {}) });
1090  }).catch(async ($, e, next) => {
1091    if (next.called) return next(e);
1092    await recordHookError($, config, 'prompt.submit');
1093    return { drop: MESSAGES.blockedPrompt };
1094  });
1095
1096  // Context blocks of the first user message: CLAUDE.md files and `userEmail`.
1097  on('prompt.context', async ($, e, next) => {
1098    if (!config.enabled) return next(e);
1099    const masker = createMasker(await currentMatching($, ctx), await vaultNow($));
1100    const blocks = e.blocks.map(block => ({ ...block, text: masker.mask(block.text) }));
1101    await record(
1102      $,
1103      config,
1104      masker.additions(),
1105      masker.count() > 0 ? metricsChange.masked('context', masker.count(), 'prompt.context') : undefined,
1106      masker.notes(),
1107    );
1108    const changed = blocks.some((block, index) => block.text !== e.blocks[index]?.text);
1109    return next(changed ? { blocks } : e);
1110  }).catch(async ($, e, next) => {
1111    if (next.called) return next(e);
1112    await recordHookError($, config, 'prompt.context');
1113    // Mask without recording: the originals cannot be restored, but none leak.
1114    return next({
1115      blocks: e.blocks.map(block => ({ ...block, text: maskText(block.text, fallbackMatching(ctx), lastVault).text })),
1116    });
1117  });
1118
1119  // Reminders, mentioned files and hook context the engine injects per request.
1120  on('prompt.attachment', async ($, e, next) => {
1121    if (!config.enabled) return next(e);
1122    const masked = maskText(e.text, await currentMatching($, ctx), await vaultNow($));
1123    await record(
1124      $,
1125      config,
1126      masked.additions,
1127      masked.count > 0 ? metricsChange.masked('attachment', masked.count, 'prompt.attachment') : undefined,
1128      masked.notes,
1129    );
1130    return next({ ...e, text: masked.text });
1131  }).catch(async ($, e, next) => {
1132    if (next.called) return next(e);
1133    await recordHookError($, config, 'prompt.attachment');
1134    return { text: null };
1135  });
1136
1137  // The system prompt (memory, environment and output-style sections), plus a
1138  // note so the model treats placeholders as ordinary values instead of refusing them.
1139  on('prompt.compose', async ($, e, next) => {
1140    const composed = await next(e);
1141    if (!config.enabled) return composed;
1142    const masker = createMasker(await currentMatching($, ctx), await vaultNow($));
1143    const sections = composed.sections.map(section => ({ ...section, text: masker.mask(section.text) }));
1144    await record(
1145      $,
1146      config,
1147      masker.additions(),
1148      masker.count() > 0 ? metricsChange.masked('systemPrompt', masker.count(), 'prompt.compose') : undefined,
1149      masker.notes(),
1150    );
1151    return { sections: [...sections, placeholderSection(config)] };
1152  }).catch(async ($, e, next) => {
1153    const composed = await next(e);
1154    if (!config.enabled) return composed;
1155    await recordHookError($, config, 'prompt.compose');
1156    return {
1157      sections: [
1158        ...composed.sections.map(section => ({
1159          ...section,
1160          text: maskText(section.text, fallbackMatching(ctx), lastVault).text,
1161        })),
1162        placeholderSection(config),
1163      ],
1164    };
1165  });
1166
1167  on('session.append', async ($, e, next) => {
1168    if (!config.enabled) return next(e);
1169    const matching = await currentMatching($, ctx);
1170    const result = maskContentBlocks(e.message.content, matching, await vaultNow($), policy);
1171    const changes = [
1172      ...(result.count > 0 ? [metricsChange.masked('conversation', result.count, 'session.append')] : []),
1173      ...(result.withheldMedia > 0 ? [metricsChange.media(result.withheldMedia)] : []),
1174    ];
1175    await record($, config, result.additions, changes.length > 0 ? combine(...changes) : undefined, result.notes);
1176    // A value first seen in a tool result is described right there, not a prompt later (main conversation only).
1177    let content = result.content;
1178    const last = content.findLastIndex(block => block.type === 'tool_result');
1179    if (e.agentId === undefined && last !== -1) {
1180      const fresh = await untoldNotes($, {}, {});
1181      if (fresh.length > 0) {
1182        const masker = createMasker(matching, await vaultNow($));
1183        const note = legendText(fresh, MESSAGES.legendHeader, masker.mask);
1184        await record($, config, masker.additions(), undefined, masker.notes());
1185        content = content.map((block, index) => (index === last ? withNote(block, note) : block));
1186        await markTold($, fresh);
1187      }
1188    }
1189    return next({ ...e, message: { ...e.message, content } });
1190  }).catch(async ($, e, next) => {
1191    if (next.called) return next(e);
1192    await recordHookError($, config, 'session.append');
1193    let content;
1194    try {
1195      content = maskContentBlocks(e.message.content, fallbackMatching(ctx), lastVault, policy).content;
1196    } catch {
1197      content = withholdContentBlocks(e.message.content);
1198    }
1199    return next({ ...e, message: { ...e.message, content } });
1200  });
hooks/config.ts 188 lines
1import { normalizeCustomValues, type MatchingOptions } from './matcher.ts';
2import { patternDetector, patternProblem, type PatternRule } from './patterns.ts';
3import { DEFAULT_POLICY, RESTORE_MODES, parsePatternList, type RestoreMode, type RestorePolicy } from './policy.ts';
4import { STATUS_LINES, type StatusLine } from './status.ts';
5import { selectDetectors } from './detectors.ts';
6import {
7  RULE_LABELS,
8  SCAN_FILES_CEILING,
9  SCAN_MAX_FILES,
10  SCAN_SKIP_DIRS,
11  DISPLAY_ROWS,
12  type DisplayRow,
13  DEFAULT_ID_MODE,
14  PLACEHOLDER_ID_MODES,
15  type PlaceholderIdMode,
16  SESSION_MAP_DAYS,
17  SESSION_MAP_DAYS_CEILING,
18} from './defaults.ts';
19import {
20  createScheme,
21  DEFAULT_FORMAT,
22  DEFAULT_ID_LENGTH,
23  formatProblem,
24  LABEL_PATTERN,
25  type PlaceholderScheme,
26} from './placeholder.ts';
27
28/** Plugin options as Claude Code hands them to `register` (PluginOptions). */
29export type RawOptions = Readonly<Record<string, string | number | boolean | readonly string[]>>;
30
31export interface RedactRestoreConfig {
32  readonly enabled: boolean;
33  readonly matching: MatchingOptions;
34  /** Count of configured custom values ignored as too short; never the values. */
35  readonly ignoredShortValues: number;
36  /** Patterns from `custom_patterns` that passed the checks, and how many did not (never which). */
37  readonly optionPatterns: readonly PatternRule[];
38  readonly invalidPatterns: number;
39  /** Detector ids active this session (for the model note and status). */
40  readonly detectorIds: readonly string[];
41  /** Names in enable_detectors / disable_detectors that match no detector or group. */
42  readonly unknownDetectors: readonly string[];
43  /** Why `placeholder_format` was rejected (the default is used), or undefined. */
44  readonly formatProblem: string | undefined;
45  readonly placeholders: PlaceholderScheme;
46  /** `placeholder_ids`: random per session, or stable across sessions. */
47  readonly placeholderIds: PlaceholderIdMode;
48  readonly restoreDisplay: boolean;
49  /** Rendered rows restored for display (`restore_display_rows`). */
50  readonly displayRows: readonly DisplayRow[];
51  /** Also restore in the live streamed reply (the engine records that text too; it is re-masked on store). */
52  readonly restoreStreaming: boolean;
53  readonly restore: RestorePolicy;
54  readonly allowMedia: boolean;
55  readonly statusLine: StatusLine;
56  /** Template overriding the status_line preset; empty uses the preset. */
57  readonly statusFormat: string;
58  /** Write per-session counts for a custom status-line script. */
59  readonly statusFile: boolean;
60  /**
61   * Keep each session's placeholder map in a local file so `/resume` restores
62   * (`persist_session_map`); delete files untouched for `days` (`session_map_days`, 0 = never).
63   */
64  readonly sessionMap: { readonly persist: boolean; readonly days: number };
65  /** Labels offered in the pane's rule form (`rule_labels`, or the built-in list). */
66  readonly ruleLabels: readonly string[];
67  /** A path scan's limits: folders it skips (built-ins plus `scan_skip_dirs`) and files it reads at most. */
68  readonly scan: { readonly skipDirs: readonly string[]; readonly maxFiles: number };
69  readonly outboundAudit: boolean;
70}
71
72function flag(options: RawOptions, key: string, fallback: boolean): boolean {
73  const value = options[key];
74  return typeof value === 'boolean' ? value : fallback;
75}
76
77function patterns(options: RawOptions, key: string, fallback: readonly string[]): readonly string[] {
78  const value = options[key];
79  if (typeof value === 'string') return parsePatternList(value);
80  if (Array.isArray(value)) return value.flatMap(item => parsePatternList(item));
81  return fallback;
82}
83
84/** Known row names, case-insensitive; unknown names are dropped. */
85function displayRows(names: readonly string[]): DisplayRow[] {
86  const wanted = new Set(names.map(name => name.toLowerCase()));
87  return DISPLAY_ROWS.filter(row => wanted.has(row.toLowerCase()));
88}
89
90function restoreMode(value: RawOptions[string] | undefined): RestoreMode {
91  return RESTORE_MODES.find(mode => mode === value) ?? DEFAULT_POLICY.mode;
92}
93
94function statusLine(value: RawOptions[string] | undefined): StatusLine {
95  return STATUS_LINES.find(line => line === value) ?? 'count';
96}
97
98function stringList(value: RawOptions[string] | undefined): string[] {
99  if (typeof value === 'string') return [value];
100  if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string');
101  return [];
102}
103
104/** `rule_labels` as valid, upper-cased, unique labels; the built-in list when empty or all invalid. */
105function ruleLabels(value: RawOptions[string] | undefined): readonly string[] {
106  const listed = stringList(value)
107    .flatMap(item => parsePatternList(item))
108    .map(label => label.toUpperCase())
109    .filter(label => LABEL_PATTERN.test(label));
110  return listed.length > 0 ? [...new Set(listed)] : RULE_LABELS;
111}
112
113/** Defaults mirror plugin.json and lean to the safe side when a value is missing. */
114/** `stableKey` supplies the local key once loaded; it is used only when `placeholder_ids` is `stable`. */
115export function parseConfig(options: RawOptions, stableKey?: () => string | undefined): RedactRestoreConfig {
116  const custom = normalizeCustomValues(stringList(options.custom_values));
117  const caseInsensitive = flag(options, 'custom_case_insensitive', false);
118  // Each custom_patterns entry is one regex source, used whole: nothing in it is parsed.
119  const patternEntries = stringList(options.custom_patterns)
120    .map(entry => entry.trim())
121    .filter(Boolean)
122    .map((pattern): PatternRule => ({ pattern, ignoreCase: caseInsensitive, wholeWord: true, group: 0 }));
123  const optionPatterns = patternEntries.filter(rule => patternProblem(rule) === undefined);
124  const format =
125    typeof options.placeholder_format === 'string' && options.placeholder_format.trim()
126      ? options.placeholder_format.trim()
127      : DEFAULT_FORMAT;
128  const idLength =
129    typeof options.placeholder_id_length === 'number' ? options.placeholder_id_length : DEFAULT_ID_LENGTH;
130  const placeholderIds = PLACEHOLDER_ID_MODES.find(mode => mode === options.placeholder_ids) ?? DEFAULT_ID_MODE;
131  const placeholders = createScheme(format, idLength, placeholderIds === 'stable' ? stableKey : undefined);
132  const selection = selectDetectors(
133    patterns(options, 'enable_detectors', []).map(name => name.toLowerCase()),
134    patterns(options, 'disable_detectors', []).map(name => name.toLowerCase()),
135  );
136  return {
137    enabled: flag(options, 'enabled', true),
138    matching: {
139      customValues: custom.values,
140      customCaseInsensitive: caseInsensitive,
141      redactEmails: flag(options, 'redact_emails', true),
142      placeholders,
143      rules: custom.rules,
144      detectors: [
145        ...optionPatterns.map((rule, index) => patternDetector(rule, `option_pattern_${index}`)),
146        ...selection.detectors,
147      ],
148    },
149    detectorIds: selection.detectors.map(detector => detector.id),
150    unknownDetectors: selection.unknown,
151    ignoredShortValues: custom.tooShort,
152    optionPatterns,
153    invalidPatterns: patternEntries.length - optionPatterns.length,
154    formatProblem: formatProblem(format),
155    placeholders,
156    placeholderIds,
157    restoreDisplay: flag(options, 'restore_display', true),
158    displayRows: displayRows(patterns(options, 'restore_display_rows', DISPLAY_ROWS)),
159    restoreStreaming: flag(options, 'restore_streaming', true),
160    restore: {
161      mode: restoreMode(options.restore_mode),
162      keepTools: patterns(options, 'keep_tools', DEFAULT_POLICY.keepTools),
163      denyUnrestoredTools: patterns(options, 'deny_unrestored_tools', DEFAULT_POLICY.denyUnrestoredTools),
164    },
165    allowMedia: flag(options, 'allow_media', false),
166    statusLine: statusLine(options.status_line),
167    statusFormat: typeof options.status_format === 'string' ? options.status_format : '',
168    statusFile: flag(options, 'status_file', false),
169    sessionMap: {
170      persist: flag(options, 'persist_session_map', true),
171      // 0 keeps every file; a negative or missing value uses the default.
172      days:
173        typeof options.session_map_days === 'number' && options.session_map_days >= 0
174          ? Math.min(Math.floor(options.session_map_days), SESSION_MAP_DAYS_CEILING)
175          : SESSION_MAP_DAYS,
176    },
177    ruleLabels: ruleLabels(options.rule_labels),
178    scan: {
179      skipDirs: [...new Set([...SCAN_SKIP_DIRS, ...patterns(options, 'scan_skip_dirs', [])])],
180      maxFiles:
181        typeof options.scan_max_files === 'number' && options.scan_max_files > 0
182          ? Math.min(Math.floor(options.scan_max_files), SCAN_FILES_CEILING)
183          : SCAN_MAX_FILES,
184    },
185    outboundAudit: flag(options, 'outbound_audit', true),
186  };
187}
188
hooks/defaults.ts 274 lines
1/**
2 * Every tunable default in one place: limits, placeholder defaults, tool
3 * lists, detection windows, scan sources, storage keys and user-facing text.
4 * Modules import from here; person-facing options in plugin.json override the
5 * ones they name. Algorithm tables (checksum weights, character classes) stay
6 * beside their algorithms in detectors.ts and matcher.ts.
7 */
8
9// Matching
10/** Shorter literals would shred ordinary text (single letters, common pairs). */
11export const MIN_CUSTOM_VALUE_LENGTH = 3;
12/** Text blocks the outbound audit remembers as clean (a very long session); past this it starts over. */
13/** Known-value segments kept compiled, and the size under which the newest values form one segment. */
14export const KNOWN_SEGMENT_CACHE = 64;
15export const KNOWN_SEGMENT_MIN = 64;
16export const AUDIT_MEMO_LIMIT = 50_000;
17/** Nesting depth walked when restoring tool input. */
18export const MAX_INPUT_DEPTH = 32;
19
20/** Pattern rules: a safe subset, checked deterministically (JavaScript has no regex timeout). */
21export const PATTERN_LIMITS = {
22  maxLength: 512,
23  /** Open-ended repeats (`*`, `+`, `{n,}`) a pattern may use. */
24  maxFreeRepeats: 2,
25  /** The fixed text every match must contain; the regex runs only in windows around it. */
26  minKeyword: 3,
27} as const;
28
29// Placeholders
30export const DEFAULT_FORMAT = '{{PRIVATE:{label}#{id}}}';
31export const DEFAULT_ID_LENGTH = 12;
32export const MIN_ID_LENGTH = 8;
33export const MAX_ID_LENGTH = 32;
34/**
35 * How placeholder ids are made. random: new per session. stable: the same value
36 * gets the same id in every session, derived with a local key (HMAC-SHA-256).
37 */
38export const PLACEHOLDER_ID_MODES = ['random', 'stable'] as const;
39export type PlaceholderIdMode = (typeof PLACEHOLDER_ID_MODES)[number];
40export const DEFAULT_ID_MODE: PlaceholderIdMode = 'random';
41/** Plugin store key of the local key for stable ids (64 hex characters); never sent or shown. */
42export const ID_KEY_STORE_KEY = 'placeholder-id-key';
43export const ID_KEY_BYTES = 32;
44export const EMAIL_LABEL = 'EMAIL';
45export const CUSTOM_LABEL = 'CUSTOM';
46
47// Restore policy
48/** Tools that write local files: what `files` mode restores. */
49export const FILE_TOOLS: readonly string[] = ['Write', 'Edit', 'NotebookEdit'];
50/**
51 * Tools whose input reaches a model or another session. Always kept: options
52 * can add tools but never remove these, because restoring here would hand the
53 * original values to a model, which is the one thing this mod exists to stop.
54 */
55export const BUILTIN_KEEP_TOOLS: readonly string[] = [
56  // Their input becomes another model's prompt or reaches another session.
57  'Agent',
58  'Task',
59  'WebFetch',
60  'WebSearch',
61  'SendMessage',
62  'Skill',
63  'Workflow',
64  'RemoteTrigger',
65  'CronCreate',
66  'ScheduleWakeup',
67  // Their input is sent to Anthropic services.
68  'SendFeedback',
69  'PushNotification',
70  'Artifact',
71  'ArtifactData',
72  'ArtifactComments',
73];
74
75// Detection windows (gitleaks keyword pre-filter)
76/** Text around a keyword a rule can match within: before covers `[\w.-]{0,50}`-style lead-ins. */
77export const KEYWORD_WINDOW_BEFORE = 256;
78export const KEYWORD_WINDOW_AFTER = 1024;
79/** How far a match touching the window's end may continue when rescanned. */
80export const KEYWORD_RESCAN_LIMIT = 65_536;
81
82// Rules store
83/** One store key per rule or dismissed candidate, suffixed with the value's SHA-256. */
84export const RULE_PREFIX = 'rule.';
85export const IGNORED_PREFIX = 'ignored.';
86export const PATTERN_PREFIX = 'pattern.';
87export const EXPORT_FORMAT = 'redact-restore-rules';
88export const EXPORT_VERSION = 1;
89export const EXPORT_FILE = 'redact-restore-rules.json';
90/** Dots in a masked preview (`P•••• (15)`). */
91export const PREVIEW_DOTS = 4;
92
93// Session maps (`persist_session_map`)
94/** Folder under the home folder for one placeholder map per session; created owner-only where the system allows. */
95export const SESSION_MAP_DIR = '.claude/redact-restore/sessions';
96export const SESSION_MAP_FORMAT = 'redact-restore-session';
97export const SESSION_MAP_VERSION = 1;
98/** Maps untouched for longer are deleted when a session starts (`session_map_days`; 0 keeps them all). */
99export const SESSION_MAP_DAYS = 30;
100export const SESSION_MAP_DAYS_CEILING = 3650;
101
102// Display
103/**
104 * Rendered rows that can show placeholders, and the props restored for drawing
105 * on this machine. Add a row here to restore it; `restore_display_rows` picks
106 * which ones are on. Only what is drawn changes: stored rows and model input keep placeholders.
107 */
108export const DISPLAY_RESTORE_FIELDS = {
109  UserMessage: ['text'],
110  AssistantMessage: ['text'],
111  ToolUse: ['input', 'output'],
112  ToolResult: ['output'],
113  AskUserQuestion: ['questions'],
114} as const satisfies Record<string, readonly string[]>;
115export type DisplayRow = keyof typeof DISPLAY_RESTORE_FIELDS;
116export const DISPLAY_ROWS = Object.keys(DISPLAY_RESTORE_FIELDS) as DisplayRow[];
117
118// Scan
119export const SCAN_TIMEOUT_MS = 5000;
120export const SCAN_LOG_AUTHORS = 200;
121export const SCAN_MAX_FILE_BYTES = 262_144;
122/** A path scan looks at most at this many files (read or skipped), this many folders deep. */
123export const SCAN_MAX_FILES = 2000;
124/** The most `scan_max_files` may ask for. */
125export const SCAN_FILES_CEILING = 20_000;
126export const SCAN_MAX_DEPTH = 8;
127/** Folders a path scan opens at most, so a huge tree of empty or skipped folders still ends. */
128export const SCAN_MAX_DIRS = 5000;
129/** Folders a path scan skips: dependencies, build output, version control. */
130export const SCAN_SKIP_DIRS: readonly string[] = [
131  '.git',
132  'node_modules',
133  '.venv',
134  'venv',
135  '__pycache__',
136  'dist',
137  'build',
138  'target',
139  'vendor',
140  '.next',
141  'coverage',
142  '.cache',
143];
144/** Host names on private networks: candidates a path scan offers as HOST. */
145export const INTERNAL_HOST =
146  /(?<![\w.-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:internal|corp|intranet|lan|local|private|localdomain|home\.arpa)(?![\w-])/gi;
147/** Host names on private networks that say nothing about anyone. */
148export const GENERIC_INTERNAL_HOST =
149  /^(?:host\.docker\.internal|kubernetes\.default\.svc\.cluster\.local|[a-z0-9-]+\.svc\.cluster\.local)$/i;
150/** Code hosts whose names are public; their owners are still candidates. */
151export const PUBLIC_GIT_HOSTS: readonly string[] = [
152  'github.com',
153  'gitlab.com',
154  'bitbucket.org',
155  'codeberg.org',
156  'ssh.dev.azure.com',
157  'dev.azure.com',
158];
159/** Account names too common to be private. */
160export const GENERIC_ACCOUNTS: readonly string[] = [
161  'root',
162  'user',
163  'admin',
164  'runner',
165  'ubuntu',
166  'vagrant',
167  'node',
168  'default',
169  'public',
170];
171export const BOT_AUTHOR = /\[bot\]|^dependabot|^github-actions|^renovate|noreply/i;
172export const ENV_FILE = /^\.env(?:\.[\w.-]+)?$/;
173export const ENV_EXAMPLE = /\.(?:example|sample|template|dist|defaults?)$/i;
174export const SECRET_KEY = /SECRET|TOKEN|PASSW(?:OR)?D|PWD|API_?KEY|PRIVATE|CREDENTIAL|AUTH|SALT/i;
175/** Values too ordinary to suggest as rules. */
176export const TRIVIAL_VALUE =
177  /^(?:true|false|yes|no|on|off|null|none|undefined|development|production|staging|test|debug|info|warn|error|localhost|127\.0\.0\.1|0\.0\.0\.0|changeme|example|xxx+|\*+|-?\d+(?:\.\d+)?)$/i;
178/** Local-network suffixes dropped from the computer name. */
179export const LOCAL_HOST_SUFFIX = /\.(?:local|lan|home|localdomain)$/i;
180
181// Command and pane
182/** Labels offered in the pane's rule form; any other valid label can be typed. */
183/**
184 * What each common label stands for, told to the model in the placeholder note
185 * (static text, so the prompt cache keeps hitting). Labels not listed here
186 * (provider keys such as AWS_KEY) speak for themselves.
187 */
188export const LABEL_DESCRIPTIONS: Readonly<Record<string, string>> = {
189  CUSTOM: 'a private value the user configured',
190  NAME: "a person's name",
191  CUSTOMER: 'a customer name',
192  ORG: 'an organization name',
193  PROJECT: 'a project or codename',
194  USER: 'an account or user name',
195  EMAIL: 'an email address',
196  PHONE: 'a phone number',
197  ADDRESS: 'a postal address',
198  HOST: 'a private host name',
199  IP: 'an IP address',
200  MAC: 'a MAC address',
201  ENV: 'the value of an environment variable (the key stays visible)',
202  SECRET: 'a secret such as a key, token or password',
203  PASSWORD: 'a password',
204  BEARER_TOKEN: 'a bearer token',
205  JWT: 'a JSON Web Token',
206  PRIVATE_KEY: 'a private key',
207  CARD: 'a payment card number',
208};
209
210/** A rule's description: what the value is, sent to the model instead of the value. */
211export const DESCRIPTION_LIMITS = { maxLength: 120 } as const;
212/** Descriptions sent with one prompt at most; the rest go with the next one. */
213export const LEGEND_MAX_ENTRIES = 50;
214export const DESCRIPTION_HINT = `A description is one line of up to ${DESCRIPTION_LIMITS.maxLength} characters without { or }, such as "client company name".`;
215
216/** Descriptions a scan gives the rules it suggests, by where the value was found. */
217export const SCAN_DESCRIPTIONS = {
218  gitName: "the user's own name (git config)",
219  gitEmail: "the user's own email address (git config)",
220  remoteHost: 'the git server host',
221  remoteOwner: 'the git repository owner',
222  remoteRepo: 'the git repository name',
223  logName: "a commit author's name",
224  logEmail: "a commit author's email address",
225  packageName: "the package author's name",
226  packageEmail: "the package author's email address",
227  computer: "this computer's name",
228  account: "the user's account name",
229  envValue: (key: string): string => `the value of ${key}`,
230} as const;
231
232export const RULE_LABELS: readonly string[] = [
233  'CUSTOM',
234  'NAME',
235  'CUSTOMER',
236  'ORG',
237  'PROJECT',
238  'HOST',
239  'EMAIL',
240  'USER',
241  'SECRET',
242  'ADDRESS',
243  'PHONE',
244];
245/** Placeholders listed on the pane's Session tab (the newest). */
246export const SESSION_PLACEHOLDER_LIMIT = 50;
247export const COMMAND = 'redact-restore';
248export const COMMAND_HINT = 'rules | scan [path] | test | session | status | export [file] | import <file> [--replace]';
249export const PANE_ID = 'redact-restore';
250export const PANE_TITLE = 'Redact Restore';
251
252// User-facing text (never a value)
253export const MESSAGES = {
254  legendHeader:
255    "Redact Restore: what these placeholders stand for (the values stay on the user's machine; use the placeholders as they are):",
256  commandDescription: 'Redact Restore: manage private-value rules, scan for candidates, show status, import or export',
257  usage: `Usage: /redact-restore ${COMMAND_HINT}`,
258  blockedPrompt: 'Redact Restore: privacy scan failed; prompt not submitted.',
259  unscannablePrompt:
260    'Redact Restore: images and other attachments cannot be scanned for private values; remove them, ' +
261    'or turn on "Allow unscannable media" in the plugin options.',
262  restoreDenied: 'Redact Restore: a private placeholder could not be restored; tool call denied.',
263  saveFailed: 'Redact Restore could not save the rules; nothing changed.',
264  scanFailed: 'Scan failed.',
265  testFailed: 'The check failed; nothing was sent.',
266  commandFailed:
267    'Redact Restore: the command failed; nothing was changed or only part of it was. Run /redact-restore status.',
268  rulesUnreadable:
269    'Redact Restore: saved rules could not be read from the plugin store; prompt not submitted. ' +
270    'Retry, or check /redact-restore status.',
271  alreadySent: ' If the value is already in this conversation, the next request is blocked: run /clear or /rewind.',
272  withheld: '[Withheld by Redact Restore: privacy scan failed]',
273} as const;
274
hooks/matcher.ts 514 lines
1/** Pure matching code: never logs, stores, or transmits matched values. */
2import { DEFAULT_SCHEME, isUsableAlias, type PlaceholderScheme, type ValueRule } from './placeholder.ts';
3import { selectDetectors, type Detector } from './detectors.ts';
4import { KNOWN_SEGMENT_CACHE, KNOWN_SEGMENT_MIN, MIN_CUSTOM_VALUE_LENGTH } from './defaults.ts';
5
6/** A placeholder label: `CUSTOM`, `EMAIL`, a detector's label, or a user's label. */
7export type PrivacyCategory = string;
8
9export interface PrivateMatch {
10  start: number;
11  end: number;
12  value: string;
13  category: PrivacyCategory;
14  /** 0 for configured or already-known values, 1 for detectors; lower wins a tie. */
15  priority: number;
16  /** The detector's description (a pattern rule's), told to the model with the placeholder. */
17  description?: string;
18}
19
20export interface MatchingOptions {
21  readonly customValues: readonly string[];
22  readonly customCaseInsensitive: boolean;
23  readonly redactEmails: boolean;
24  /** How placeholders look; the default `{{PRIVATE:{label}#{id}}}` when absent. */
25  readonly placeholders?: PlaceholderScheme;
26  /** Per-value label or alias, keyed by the value in lower case. */
27  readonly rules?: ReadonlyMap<string, ValueRule>;
28  /** Detectors besides email; the default-on set when absent. */
29  readonly detectors?: readonly Detector[];
30}
31
32const DEFAULT_DETECTORS = selectDetectors([], []).detectors;
33
34/** A value the session vault already holds; matched literally and case-sensitively. */
35export interface KnownValue {
36  readonly value: string;
37  readonly category: PrivacyCategory;
38}
39
40export { MIN_CUSTOM_VALUE_LENGTH };
41
42const EMAIL_LOCAL = /[A-Za-z0-9._%+-]/;
43const EMAIL_DOMAIN = /[A-Za-z0-9.-]/;
44const ASCII_LETTER = /[A-Za-z]/;
45
46function escapeRegExp(value: string): string {
47  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48}
49
50export interface NormalizedValues {
51  values: string[];
52  rules: Map<string, ValueRule>;
53  /** Entries dropped as too short; never the entries themselves. */
54  tooShort: number;
55  /** Entries whose label or alias is unusable or reused. */
56  invalid: number;
57}
58
59/**
60 * `custom_values` entries from the plugin options: each entry is one value,
61 * matched exactly as typed (trimmed). Labels and aliases come only from the
62 * rules pane's fields; nothing here is parsed.
63 */
64export function normalizeCustomValues(raw: readonly string[]): NormalizedValues {
65  return normalizeRules(
66    raw
67      .map(entry => entry.trim())
68      .filter(Boolean)
69      .map(value => ({ value })),
70  );
71}
72
73/** Whether an alias overlaps another rule's value: restoring it would rewrite that value too. */
74export function aliasOverlaps(alias: string, ownValue: string, values: Iterable<string>): boolean {
75  const name = alias.toLowerCase();
76  const own = ownValue.toLowerCase();
77  for (const value of values) {
78    const other = value.toLowerCase();
79    if (other !== own && (other.includes(name) || name.includes(other))) return true;
80  }
81  return false;
82}
83
84/**
85 * Rules as structured fields: value, and optionally label or alias. Every spelling
86 * is matched; the first rule for a value (any case) gives its label or alias.
87 * A reused, unusable or overlapping alias is dropped and its value still
88 * masked with the default label: a bad alias never unmasks a value.
89 */
90export function normalizeRules(input: readonly ValueRule[]): NormalizedValues {
91  const rules = new Map<string, ValueRule>();
92  const values: string[] = [];
93  const spellings = new Set<string>();
94  const aliases = new Set<string>();
95  let tooShort = 0;
96  let invalid = 0;
97  const usable = input.filter(rule => {
98    if (rule.value.length >= MIN_CUSTOM_VALUE_LENGTH) return true;
99    tooShort += 1;
100    return false;
101  });
102  const allValues = usable.map(rule => rule.value);
103  for (const rule of usable) {
104    let kept = rule;
105    if (
106      rule.alias &&
107      (aliases.has(rule.alias) ||
108        !isUsableAlias(rule.alias, rule.value) ||
109        aliasOverlaps(rule.alias, rule.value, allValues))
110    ) {
111      invalid += 1;
112      kept = { value: rule.value, ...(rule.description ? { description: rule.description } : {}) };
113    }
114    if (!spellings.has(kept.value)) {
115      spellings.add(kept.value);
116      values.push(kept.value);
117    }
118    const key = kept.value.toLowerCase();
119    if (rules.has(key)) continue;
120    if (kept.alias) aliases.add(kept.alias);
121    rules.set(key, kept);
122  }
123  // Any spelling that ignores case makes every spelling of that value ignore case (the safe side).
124  for (const rule of usable) {
125    const key = rule.value.toLowerCase();
126    const kept = rules.get(key);
127    if (rule.ignoreCase && kept && !kept.ignoreCase) rules.set(key, { ...kept, ignoreCase: true });
128  }
129  return { values, rules, tooShort, invalid };
130}
131
132/**
133 * Linear-time email scan. A backtracking regex such as
134 * /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ is quadratic on long runs without
135 * an `@` (base64, minified files), which large tool results routinely contain.
136 * Matches what that regex would match: the leftmost local part and the longest
137 * domain that ends in a dot followed by two or more letters.
138 */
139export function findEmails(text: string): Array<{ start: number; end: number }> {
140  const found: Array<{ start: number; end: number }> = [];
141  let consumed = 0;
142  let at = text.indexOf('@');
143  while (at !== -1) {
144    let start = at;
145    while (start > consumed && EMAIL_LOCAL.test(text.charAt(start - 1))) start -= 1;
146    let runEnd = at + 1;
147    while (runEnd < text.length && EMAIL_DOMAIN.test(text.charAt(runEnd))) runEnd += 1;
148
149    let end = -1;
150    if (start < at) {
151      let dot = text.lastIndexOf('.', runEnd - 1);
152      while (dot > at + 1) {
153        let letters = dot + 1;
154        while (letters < runEnd && ASCII_LETTER.test(text.charAt(letters))) letters += 1;
155        if (letters - (dot + 1) >= 2) {
156          end = letters;
157          break;
158        }
159        dot = text.lastIndexOf('.', dot - 1);
160      }
161    }
162    if (end !== -1) {
163      found.push({ start, end });
164      consumed = end;
165      at = text.indexOf('@', end);
166    } else {
167      at = text.indexOf('@', at + 1);
168    }
169  }
170  return found;
171}
172
173/**
174 * Compiled matchers for the session's known values and tokens. The vault only
175 * grows (a /clear starts a new one), so its values are compiled in segments
176 * whose sizes are powers of two, like a binary counter: adding a value
177 * recompiles only small segments, and a large vault is never compiled whole
178 * again for one new value. Matching runs one regex per segment (a handful).
179 */
180interface KnownIndex {
181  readonly values: readonly RegExp[];
182  readonly category: ReadonlyMap<string, PrivacyCategory>;
183  /** Tokens the placeholder format does not cover: aliases and older formats. */
184  readonly tokens?: RegExp;
185}
186
187/** A 53-bit hash with the length (cyrb53): a cache key for text, not a security hash. */
188export function textHash(text: string): string {
189  let h1 = 0xdeadbeef;
190  let h2 = 0x41c6ce57;
191  for (let i = 0; i < text.length; i += 1) {
192    const ch = text.charCodeAt(i);
193    h1 = Math.imul(h1 ^ ch, 2654435761);
194    h2 = Math.imul(h2 ^ ch, 1597334677);
195  }
196  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
197  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
198  return `${text.length}:${(2097151 & h2) * 4294967296 + (h1 >>> 0)}`;
199}
200
201const segmentCache = new Map<string, RegExp>();
202
203/**
204 * One segment's alternation, cached by its whole content (another vault may hold
205 * other values at the same positions); least recently used first out.
206 */
207function segment(values: readonly string[], start: number, end: number): RegExp {
208  const key = textHash(values.slice(start, end).join('\u0000'));
209  let pattern = segmentCache.get(key);
210  if (pattern) segmentCache.delete(key);
211  else pattern = compileLiterals(values.slice(start, end), false);
212  segmentCache.set(key, pattern);
213  if (segmentCache.size > KNOWN_SEGMENT_CACHE) {
214    const oldest = segmentCache.keys().next().value;
215    if (oldest !== undefined) segmentCache.delete(oldest);
216  }
217  return pattern;
218}
219
220function segments(values: readonly string[]): RegExp[] {
221  const found: RegExp[] = [];
222  let start = 0;
223  let size = 1;
224  while (size * 2 <= values.length) size *= 2;
225  for (; start < values.length; size = Math.max(1, size / 2)) {
226    const rest = values.length - start;
227    // The tail under the smallest segment size is compiled as one piece.
228    if (rest <= KNOWN_SEGMENT_MIN) {
229      found.push(segment(values, start, values.length));
230      break;
231    }
232    if (size <= rest) {
233      found.push(segment(values, start, start + size));
234      start += size;
235    }
236  }
237  return found;
238}
239
240// Lookups derived from one known list (the vault view caches the list itself).
241const knownLookups = new WeakMap<
242  readonly KnownValue[],
243  {
244    readonly values: readonly string[];
245    readonly category: ReadonlyMap<string, PrivacyCategory>;
246    segments?: readonly RegExp[];
247  }
248>();
249const tokenPatterns = new WeakMap<readonly string[], { readonly format: string; readonly pattern?: RegExp }>();
250
251function knownIndex(known: readonly KnownValue[], tokens: readonly string[], scheme: PlaceholderScheme): KnownIndex {
252  let lookup = knownLookups.get(known);
253  if (!lookup) {
254    lookup = {
255      values: known.map(entry => entry.value),
256      category: new Map(known.map(entry => [entry.value, entry.category])),
257    };
258    knownLookups.set(known, lookup);
259  }
260  let literal = tokenPatterns.get(tokens);
261  if (literal?.format !== scheme.format) {
262    // Placeholders of the active format are found by the format's own pattern.
263    const others = tokens.filter(token => scheme.labelOf(token) === undefined);
264    literal = { format: scheme.format, ...(others.length ? { pattern: compileLiterals(others, false) } : {}) };
265    tokenPatterns.set(tokens, literal);
266  }
267  // Segments are hashed once per known list, not once per text.
268  lookup.segments ??= lookup.values.length ? segments(lookup.values) : [];
269  return {
270    values: lookup.segments,
271    category: lookup.category,
272    ...(literal.pattern ? { tokens: literal.pattern } : {}),
273  };
274}
275
276/** The custom values split by case handling, compiled once per options object. */
277const customCache = new WeakMap<MatchingOptions, { exact?: RegExp; ignoreCase?: RegExp }>();
278
279function customPatterns(options: MatchingOptions): { exact?: RegExp; ignoreCase?: RegExp } {
280  let cached = customCache.get(options);
281  if (!cached) {
282    const insensitive = (value: string) =>
283      options.rules?.get(value.toLowerCase())?.ignoreCase ?? options.customCaseInsensitive;
284    const exact = options.customValues.filter(value => !insensitive(value));
285    const ignoreCase = options.customValues.filter(insensitive);
286    cached = {
287      ...(exact.length ? { exact: compileLiterals(exact, false) } : {}),
288      ...(ignoreCase.length ? { ignoreCase: compileLiterals(ignoreCase, true) } : {}),
289    };
290    customCache.set(options, cached);
291  }
292  return cached;
293}
294
295/** One alternation of literals, longest first, so the leftmost match is also the longest. */
296function compileLiterals(values: readonly string[], caseInsensitive: boolean): RegExp {
297  const sorted = [...new Set(values)].sort((a, b) => b.length - a.length);
298  return new RegExp(sorted.map(escapeRegExp).join('|'), caseInsensitive ? 'giu' : 'gu');
299}
300
301type Range = { readonly start: number; readonly end: number };
302
303/** Sorted, non-overlapping ranges. */
304function mergeRanges(ranges: readonly Range[]): Range[] {
305  const sorted = [...ranges].sort((a, b) => a.start - b.start);
306  const merged: Range[] = [];
307  for (const range of sorted) {
308    const last = merged[merged.length - 1];
309    if (last && range.start <= last.end)
310      merged[merged.length - 1] = { start: last.start, end: Math.max(last.end, range.end) };
311    else merged.push(range);
312  }
313  return merged;
314}
315
316/** The text with each range replaced by `x` of the same length: offsets stay the same. */
317function blankRanges(text: string, ranges: readonly Range[]): string {
318  const parts: string[] = [];
319  let last = 0;
320  for (const range of ranges) {
321    parts.push(text.slice(last, range.start), 'x'.repeat(range.end - range.start));
322    last = range.end;
323  }
324  parts.push(text.slice(last));
325  return parts.join('');
326}
327
328/**
329 * Candidates with placeholder ranges cut out. A configured or known value that
330 * touches a placeholder is dropped, as before; a detector's match keeps its
331 * parts outside placeholders that are long enough to be a value. Candidates
332 * arrive sorted by start, so one pointer walks the ranges.
333 */
334function outsideRanges(candidates: readonly PrivateMatch[], ranges: readonly Range[], text: string): PrivateMatch[] {
335  const kept: PrivateMatch[] = [];
336  let first = 0;
337  for (const candidate of candidates) {
338    while (first < ranges.length && (ranges[first]?.end ?? 0) <= candidate.start) first += 1;
339    if (first >= ranges.length || (ranges[first]?.start ?? Infinity) >= candidate.end) {
340      kept.push(candidate);
341      continue;
342    }
343    if (candidate.priority === 0) continue;
344    let cursor = candidate.start;
345    const piece = (start: number, end: number) => {
346      if (end - start >= MIN_CUSTOM_VALUE_LENGTH)
347        kept.push({ ...candidate, start, end, value: text.slice(start, end) });
348    };
349    for (let index = first; index < ranges.length && (ranges[index]?.start ?? Infinity) < candidate.end; index += 1) {
350      const range = ranges[index] as Range;
351      piece(cursor, Math.min(range.start, candidate.end));
352      cursor = Math.max(cursor, range.end);
353    }
354    piece(cursor, candidate.end);
355  }
356  return kept;
357}
358
359/** For each outranked label, the winners' starts and the furthest end so far: overlap tests by binary search. */
360function winnersByLabel(
361  winners: readonly PrivateMatch[],
362  labels: ReadonlyMap<PrivateMatch, readonly string[]>,
363): Map<string, { readonly starts: number[]; readonly maxEnd: number[] }> {
364  const byLabel = new Map<string, PrivateMatch[]>();
365  for (const winner of winners) {
366    for (const label of labels.get(winner) ?? []) {
367      const list = byLabel.get(label);
368      if (list) list.push(winner);
369      else byLabel.set(label, [winner]);
370    }
371  }
372  const result = new Map<string, { starts: number[]; maxEnd: number[] }>();
373  for (const [label, list] of byLabel) {
374    list.sort((a, b) => a.start - b.start);
375    let furthest = -1;
376    result.set(label, {
377      starts: list.map(winner => winner.start),
378      maxEnd: list.map(winner => (furthest = Math.max(furthest, winner.end))),
379    });
380  }
381  return result;
382}
383
384/** Whether a winner that starts before the candidate ends reaches past its start. */
385function overlapsWinner(
386  winners: { readonly starts: readonly number[]; readonly maxEnd: readonly number[] } | undefined,
387  candidate: PrivateMatch,
388): boolean {
389  if (!winners) return false;
390  let low = 0;
391  let high = winners.starts.length;
392  while (low < high) {
393    const mid = (low + high) >> 1;
394    if ((winners.starts[mid] ?? 0) < candidate.end) low = mid + 1;
395    else high = mid;
396  }
397  return low > 0 && (winners.maxEnd[low - 1] ?? -1) > candidate.start;
398}
399
400function protectedRanges(text: string, scheme: PlaceholderScheme, literal: RegExp | undefined) {
401  const ranges = [...text.matchAll(scheme.pattern)].map(match => ({
402    start: match.index,
403    end: match.index + match[0].length,
404  }));
405  if (literal) {
406    for (const match of text.matchAll(literal)) {
407      ranges.push({ start: match.index, end: match.index + match[0].length });
408    }
409  }
410  return ranges;
411}
412
413export function findPrivateMatches(
414  text: string,
415  options: MatchingOptions,
416  known: readonly KnownValue[] = [],
417  knownTokens: readonly string[] = [],
418): PrivateMatch[] {
419  if (text.length === 0) return [];
420  const candidates: PrivateMatch[] = [];
421  const index = knownIndex(known, knownTokens, options.placeholders ?? DEFAULT_SCHEME);
422  if (index.tokens) index.tokens.lastIndex = 0;
423  const guarded = mergeRanges(protectedRanges(text, options.placeholders ?? DEFAULT_SCHEME, index.tokens));
424  // Detectors read placeholders as filler of the same length, so a raw value
425  // right next to one (`KEY={{…}}tail`, `postgres://{{…}}:REDACTED@host`) is
426  // still found; the parts inside placeholders are cut away below.
427  const scanText = guarded.length > 0 ? blankRanges(text, guarded) : text;
428
429  // Literal values are treated as literals, not as regexes. A rule's own
430  // ignoreCase wins over the option, so the values form two alternations.
431  const { exact, ignoreCase } = customPatterns(options);
432  for (const custom of [exact, ignoreCase]) {
433    if (!custom) continue;
434    custom.lastIndex = 0;
435    for (const match of text.matchAll(custom)) {
436      candidates.push({
437        start: match.index,
438        end: match.index + match[0].length,
439        value: match[0],
440        category: 'CUSTOM',
441        priority: 0,
442      });
443    }
444  }
445  // Values already in the vault stay masked even after a config change or when a
446  // detector would not find them again (e.g. an Edit result echoing restored text).
447  const knownCategory = index.category;
448  for (const knownPattern of index.values) {
449    knownPattern.lastIndex = 0;
450    for (const match of text.matchAll(knownPattern)) {
451      candidates.push({
452        start: match.index,
453        end: match.index + match[0].length,
454        value: match[0],
455        category: knownCategory.get(match[0]) ?? 'CUSTOM',
456        priority: 0,
457      });
458    }
459  }
460  if (options.redactEmails) {
461    for (const { start, end } of findEmails(scanText)) {
462      candidates.push({ start, end, value: text.slice(start, end), category: 'EMAIL', priority: 1 });
463    }
464  }
465  const outranking: PrivateMatch[] = [];
466  const outranked = new Map<PrivateMatch, readonly string[]>();
467  for (const detector of options.detectors ?? DEFAULT_DETECTORS) {
468    for (const { start, end, label } of detector.find(scanText)) {
469      const candidate = {
470        start,
471        end,
472        value: text.slice(start, end),
473        category: label ?? detector.label,
474        priority: detector.rank ?? 1,
475        ...(detector.description ? { description: detector.description } : {}),
476      };
477      candidates.push(candidate);
478      if (detector.outranks) {
479        outranking.push(candidate);
480        outranked.set(candidate, detector.outranks);
481      }
482    }
483  }
484  // A detector that knows better removes overlapping matches of the labels it outranks.
485  if (outranking.length > 0) {
486    const beats = winnersByLabel(outranking, outranked);
487    const survivors = candidates.filter(candidate => !overlapsWinner(beats.get(candidate.category), candidate));
488    candidates.splice(0, candidates.length, ...survivors);
489  }
490
491  // Leftmost wins, then longest; on a tie, configured values beat detectors.
492  const byPosition = (a: PrivateMatch, b: PrivateMatch) =>
493    a.start - b.start || b.end - b.start - (a.end - a.start) || a.priority - b.priority;
494  candidates.sort(byPosition);
495  const pieces = guarded.length > 0 ? outsideRanges(candidates, guarded, text).sort(byPosition) : candidates;
496
497  // A candidate that starts inside the previous span but runs past it is merged
498  // into that span: dropping it would leave its tail (an email's domain, say) in
499  // clear text, which the outbound audit would then refuse on every request.
500  const selected: PrivateMatch[] = [];
501  for (const candidate of pieces) {
502    const last = selected[selected.length - 1];
503    if (last && candidate.start < last.end) {
504      if (candidate.end > last.end) {
505        last.end = candidate.end;
506        last.value = text.slice(last.start, last.end);
507      }
508      continue;
509    }
510    selected.push({ ...candidate });
511  }
512  return selected;
513}
514
hooks/pane.tsx 723 lines
1/**
2 * The rules and scan pane, drawn from plain data and handler closures. Values
3 * appear here only, on this screen; they never reach the model. Masked unless
4 * the person asks to show them.
5 */
6import type { Elements } from 'claude-code';
7import { CUSTOM_LABEL, SESSION_PLACEHOLDER_LIMIT } from './defaults.ts';
8import type { Metrics } from './metrics.ts';
9import { describePatternProblem, patternDetector, patternProblem, type PatternRule } from './patterns.ts';
10import { LABEL_PATTERN, type PlaceholderScheme, type ValueRule } from './placeholder.ts';
11import { maskPreview, type RuleProblem, type StoredPattern, type StoredRule } from './rules.ts';
12import type { ScanCandidate } from './scan.ts';
13import type { StatusStats } from './status.ts';
14
15export type PaneTab = 'rules' | 'scan' | 'session' | 'test';
16
17/** The Test tab's result: computed on this machine, never recorded in the session or sent anywhere. */
18export interface TestView {
19  readonly text: string;
20  /** The text as the model would receive it. */
21  readonly masked: string;
22  /** Each value found, once, in order: its label and the original. */
23  readonly found: ReadonlyArray<{ readonly label: string; readonly value: string }>;
24}
25
26/** One placeholder this session minted, and the value it stands for (shown masked unless revealed). */
27export interface SessionEntry {
28  readonly placeholder: string;
29  readonly label: string;
30  readonly value: string;
31}
32
33/** The current session, as counts and the placeholders it made. */
34export interface SessionView {
35  readonly stats: StatusStats;
36  readonly metrics: Metrics;
37  readonly byLabel: ReadonlyArray<readonly [string, number]>;
38  /** The newest placeholders, oldest first. */
39  readonly entries: readonly SessionEntry[];
40  readonly total: number;
41  readonly placeholderFormat: string;
42  readonly detectors: number;
43  readonly savedRules: number;
44  readonly optionRules: number;
45}
46
47/** Builds the session view from the vault (placeholder to value) and the counters. */
48export function sessionView(
49  vault: Readonly<Record<string, string>>,
50  metrics: Metrics,
51  stats: StatusStats,
52  scheme: PlaceholderScheme,
53  counts: { detectors: number; savedRules: number; optionRules: number },
54): SessionView {
55  const all = Object.entries(vault).map(([placeholder, value]) => ({
56    placeholder,
57    value,
58    label: scheme.labelOf(placeholder) ?? 'ALIAS',
59  }));
60  const byLabel = new Map<string, number>();
61  for (const entry of all) byLabel.set(entry.label, (byLabel.get(entry.label) ?? 0) + 1);
62  return {
63    stats,
64    metrics,
65    byLabel: [...byLabel.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
66    entries: all.slice(-SESSION_PLACEHOLDER_LIMIT),
67    total: all.length,
68    placeholderFormat: scheme.format,
69    ...counts,
70  };
71}
72
73/** `Bash 2, Write 1`, or `none`. */
74function tally(counts: Readonly<Record<string, number>> | ReadonlyArray<readonly [string, number]>): string {
75  const pairs = Array.isArray(counts) ? counts : Object.entries(counts).sort((a, b) => b[1] - a[1]);
76  return pairs.length ? pairs.map(([name, count]) => `${name} ${count}`).join(' · ') : 'none';
77}
78
79function clock(ms: number): string {
80  if (!ms) return '—';
81  const at = new Date(ms);
82  return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
83}
84
85export type DraftMode = 'label' | 'alias';
86
87/** The rule form: what the person has typed and picked so far (this screen only). */
88export type DraftKind = 'value' | 'pattern';
89export type DraftCase = 'default' | 'yes' | 'no';
90
91export interface RuleDraft {
92  /** An exact value, or a pattern (regex). */
93  readonly kind: DraftKind;
94  readonly value: string;
95  readonly mode: DraftMode;
96  readonly label: string;
97  /** True when the label is typed rather than picked from the list. */
98  readonly customLabel: boolean;
99  readonly alias: string;
100  /** The form was filled from a saved rule; saving updates it. */
101  readonly editing: boolean;
102  /** Ignore case: follow the `custom_case_insensitive` option, or yes / no for this rule. */
103  readonly ignoreCase: DraftCase;
104  readonly pattern: string;
105  readonly wholeWord: boolean;
106  /** 0 masks the whole match; N masks capture group N. */
107  readonly group: number;
108  /** A sample the pattern must match; checked on save, never stored. */
109  readonly example: string;
110  /** What the value is, told to the model ("client company name"); optional. */
111  readonly description: string;
112}
113
114export const EMPTY_DRAFT: RuleDraft = {
115  kind: 'value',
116  ignoreCase: 'default',
117  pattern: '',
118  wholeWord: true,
119  group: 0,
120  example: '',
121  description: '',
122  value: '',
123  mode: 'label',
124  label: CUSTOM_LABEL,
125  customLabel: false,
126  alias: '',
127  editing: false,
128};
129
130export interface PaneView {
131  readonly view: PaneTab;
132  readonly reveal: boolean;
133  readonly notice: string;
134  readonly draft: RuleDraft;
135  readonly test?: TestView;
136}
137
138export interface ScanView {
139  readonly candidates: readonly ScanCandidate[];
140  /** Found values a rule, the email scanner or a detector already masks. */
141  readonly covered: number;
142  /** Found values dismissed before. */
143  readonly ignored: number;
144  /** A path scan: the folder or file as shown (`~/…`), files read, and values already masked by label. */
145  readonly target?: string;
146  readonly files?: number;
147  /** Files a path scan looked at but left out: binary, too large or unreadable. */
148  readonly skipped?: number;
149  /** A path scan stopped before the end of the tree (file, folder or depth limit). */
150  readonly partial?: boolean;
151  readonly kinds?: ReadonlyArray<readonly [string, number]>;
152}
153
154export interface PaneData {
155  readonly view: PaneView;
156  readonly stored: readonly StoredRule[];
157  /** Rules from the plugin options (keychain): shown, never edited here. */
158  readonly optionRules: readonly ValueRule[];
159  readonly scan: ScanView | null;
160  readonly session: SessionView;
161  /** False where the surface has no text field (mobile). */
162  readonly canType: boolean;
163  /** A sample placeholder for a label, in the active format. */
164  readonly example: (label: string) => string;
165  /** Labels offered in the rule form (see `formLabels`). */
166  readonly labels: readonly string[];
167  readonly patterns: readonly StoredPattern[];
168  /** Patterns from the plugin options (keychain), counted only. */
169  readonly optionPatterns: number;
170  /** The `custom_case_insensitive` option: what "Follow the option" means. */
171  readonly caseDefault: boolean;
172}
173
174export interface PaneActions {
175  show(view: PaneTab): void;
176  toggleReveal(): void;
177  setDraft(change: Partial<RuleDraft>): void;
178  /** Saves the form; `patch` is a field's latest text when Enter submitted it. */
179  saveDraft(patch?: Partial<RuleDraft>): void;
180  clearDraft(): void;
181  edit(rule: StoredRule): void;
182  remove(value: string): void;
183  removePattern(pattern: StoredPattern): void;
184  scan(): void;
185  accept(candidate: ScanCandidate): void;
186  acceptAll(): void;
187  ignore(candidate: ScanCandidate): void;
188  close(): void;
189  /** Checks text in the Test tab; empty clears it. */
190  test(text: string): void;
191}
192
193export type PaneElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button'> & {
194  readonly Input?: Elements['desktop']['Input'];
195  readonly Select?: Elements['desktop']['Select'];
196};
197
198export const EMPTY_VIEW: PaneView = { view: 'rules', reveal: false, notice: '', draft: EMPTY_DRAFT };
199
200const OTHER_LABEL = '__other';
201
202/** What is missing or wrong in the form before it can be saved (the full check runs on save). */
203export function draftProblem(draft: RuleDraft): RuleProblem | undefined {
204  if (draft.kind === 'pattern') {
205    const label = draft.label.trim();
206    if (!label) return 'no-label';
207    return LABEL_PATTERN.test(label) ? undefined : 'invalid-label';
208  }
209  if (!draft.value.trim()) return 'empty';
210  if (draft.mode === 'alias') return draft.alias.trim() ? undefined : 'no-alias';
211  const label = draft.label.trim();
212  if (!label) return 'no-label';
213  return LABEL_PATTERN.test(label) ? undefined : 'invalid-label';
214}
215
216/** The pattern rule the form describes; `caseDefault` is the `custom_case_insensitive` option. */
217export function draftPattern(draft: RuleDraft, caseDefault: boolean): PatternRule {
218  const label = draft.label.trim().toUpperCase();
219  return {
220    pattern: draft.pattern.trim(),
221    ...(label && label !== CUSTOM_LABEL ? { label } : {}),
222    ignoreCase: draft.ignoreCase === 'default' ? caseDefault : draft.ignoreCase === 'yes',
223    wholeWord: draft.wholeWord,
224    group: draft.group,
225    example: draft.example,
226    ...(draft.description.trim() ? { description: draft.description.trim() } : {}),
227  };
228}
229
230/** A pattern's example as the model would see it, or why the pattern cannot be saved yet. */
231export function patternPreview(draft: RuleDraft, caseDefault: boolean, example: (label: string) => string): string {
232  if (!draft.pattern.trim()) return '(type a pattern and an example it must match)';
233  const rule = draftPattern(draft, caseDefault);
234  const problem = patternProblem(rule);
235  if (problem) return `(${describePatternProblem(problem)})`;
236  const spans = patternDetector(rule, 'preview').find(draft.example);
237  let text = draft.example;
238  for (const { start, end } of [...spans].reverse()) {
239    text = `${text.slice(0, start)}${example(rule.label ?? CUSTOM_LABEL)}${text.slice(end)}`;
240  }
241  return text;
242}
243
244/** What the model would see for the form as filled in. */
245export function draftPreview(draft: RuleDraft, example: (label: string) => string): string {
246  if (draft.mode === 'alias') return draft.alias.trim() || '(type an alias)';
247  const label = draft.label.trim().toUpperCase();
248  if (!label) return '(type a label)';
249  return LABEL_PATTERN.test(label) ? example(label) : '(not a valid label)';
250}
251
252/** The form's label list: CUSTOM, the configured labels, then every label a rule already uses. */
253export function formLabels(configured: readonly string[], rules: ReadonlyArray<Pick<ValueRule, 'label'>>): string[] {
254  const used = rules.flatMap(rule => (rule.label ? [rule.label] : []));
255  return [...new Set([CUSTOM_LABEL, ...configured, ...used])];
256}
257
258/** The form filled from a saved rule, for editing its label or alias. */
259export function draftFrom(
260  rule: Pick<StoredRule, 'value' | 'label' | 'alias' | 'ignoreCase' | 'description'>,
261  labels: readonly string[],
262): RuleDraft {
263  const label = rule.label ?? CUSTOM_LABEL;
264  return {
265    ...EMPTY_DRAFT,
266    ignoreCase: rule.ignoreCase === undefined ? 'default' : rule.ignoreCase ? 'yes' : 'no',
267    value: rule.value,
268    mode: rule.alias ? 'alias' : 'label',
269    label,
270    customLabel: !labels.includes(label),
271    alias: rule.alias ?? '',
272    description: rule.description ?? '',
273    editing: true,
274  };
275}
276
277function tag(rule: Pick<StoredRule, 'label' | 'alias'>): string {
278  if (rule.alias) return `alias ${rule.alias}`;
279  return rule.label ?? 'CUSTOM';
280}
281
282export function drawPane(ui: PaneElements, data: PaneData, act: PaneActions) {
283  const { Box, Text, Button, Input, Select } = ui;
284  const draft: RuleDraft = { ...EMPTY_DRAFT, ...data.view.draft };
285  const shown = (value: string) => (data.view.reveal ? value : maskPreview(value));
286  const tab = data.view.view;
287
288  const header = (
289    <Box flexDirection="row" gap={1} flexWrap="wrap">
290      <Button key="tab-rules" variant={tab === 'rules' ? 'primary' : 'secondary'} onPress={() => act.show('rules')}>
291        Rules
292      </Button>
293      <Button key="tab-scan" variant={tab === 'scan' ? 'primary' : 'secondary'} onPress={() => act.show('scan')}>
294        Scan
295      </Button>
296      <Button
297        key="tab-session"
298        variant={tab === 'session' ? 'primary' : 'secondary'}
299        onPress={() => act.show('session')}
300      >
301        Session
302      </Button>
303      <Button key="tab-test" variant={tab === 'test' ? 'primary' : 'secondary'} onPress={() => act.show('test')}>
304        Test
305      </Button>
306      <Button key="reveal" onPress={() => act.toggleReveal()}>
307        {data.view.reveal ? 'Hide values' : 'Show values'}
308      </Button>
309      <Button key="close" role="dismiss" onPress={() => act.close()}>
310        Close
311      </Button>
312    </Box>
313  );
314
315  const notice = data.view.notice ? <Text bold>{data.view.notice}</Text> : null;
316
317  if (tab === 'test') {
318    const result = data.view.test;
319    return (
320      <Box flexDirection="column" gap={1}>
321        {header}
322        {notice}
323        <Text dimColor>
324          Paste text to see what the model would receive. It is checked on this machine: nothing is sent, saved or added
325          to the session.
326        </Text>
327        {data.canType && Input ? (
328          <Input
329            key="test-input"
330            label="Text to check"
331            placeholder="Paste a log, config or message"
332            submitLabel="check"
333            autoFocus
334            onSubmit={text => act.test(text)}
335          />
336        ) : (
337          <Text dimColor>Testing text needs the terminal or the desktop app.</Text>
338        )}
339        {result ? (
340          <Box flexDirection="column" gap={1}>
341            <Box flexDirection="column">
342              <Text bold>The model would see</Text>
343              <Text>{result.masked}</Text>
344            </Box>
345            <Box flexDirection="column">
346              <Text bold>
347                {result.found.length === 0
348                  ? 'Nothing found: this text would be sent as it is.'
349                  : `${result.found.length} value(s) found`}
350              </Text>
351              {result.found.map((item, index) => (
352                <Box key={`found-${index}`} flexDirection="row" gap={1} flexWrap="wrap">
353                  <Text dimColor>{item.label}</Text>
354                  <Text>{shown(item.value)}</Text>
355                </Box>
356              ))}
357              {result.found.length === 0 ? (
358                <Text dimColor>Something private in it? Add it as a rule on the Rules tab.</Text>
359              ) : null}
360            </Box>
361            <Button key="test-clear" onPress={() => act.test('')}>
362              Clear
363            </Button>
364          </Box>
365        ) : null}
366      </Box>
367    );
368  }
369
370  if (tab === 'session') {
371    const { stats, metrics: m } = data.session;
372    const row = (name: string, text: string, key: string) => (
373      <Box key={key} flexDirection="row" gap={1}>
374        <Box width={10} flexShrink={0}>
375          <Text bold>{name}</Text>
376        </Box>
377        <Text>{text}</Text>
378      </Box>
379    );
380    const errors = Object.values(m.hookErrors).reduce((sum, count) => sum + count, 0);
381    const block = m.lastBlock
382      ? ` · last block: ${m.lastBlock.kind}${m.lastBlock.block ? ` in ${m.lastBlock.block}` : ''}`
383      : '';
384    return (
385      <Box flexDirection="column" gap={1}>
386        {header}
387        {notice}
388        <Box flexDirection="column">
389          {row(
390            'State',
391            `${stats.state} · since ${clock(m.startedAt)} · last ${m.lastEvent} at ${clock(m.updatedAt)}`,
392            's-state',
393          )}
394          {row(
395            'Masked',
396            `${stats.masked} values · ${stats.occurrences} replacements · ${stats.lastTurn} since last prompt`,
397            's-masked',
398          )}
399          {row('Kinds', tally(data.session.byLabel), 's-kinds')}
400          {row('Sources', tally(m.bySource), 's-sources')}
401          {row(
402            'Tools',
403            `restored ${m.restoredCalls} (${tally(m.restoredByTool)}) · kept ${m.keptCalls} · denied ${m.deniedCalls}`,
404            's-tools',
405          )}
406          {row('Requests', `audited ${m.audits} · blocked ${m.blocked}${block}`, 's-requests')}
407          {row(
408            'Other',
409            `media withheld ${m.mediaWithheld} · prompts refused ${m.promptsDropped} · hook errors ${errors}${errors ? ` (${tally(m.hookErrors)})` : ''}`,
410            's-other',
411          )}
412          {row(
413            'Settings',
414            `restore ${stats.mode} · audit ${stats.audit ? 'on' : 'off'} · streaming ${stats.streaming ? 'on' : 'off'} · ` +
415              `${data.session.detectors} detectors · rules ${data.session.savedRules} saved + ${data.session.optionRules} options`,
416            's-settings',
417          )}
418          {row('Format', data.session.placeholderFormat, 's-format')}
419        </Box>
420        <Box flexDirection="column">
421          <Text bold>
422            Placeholders ({data.session.total}
423            {data.session.total > data.session.entries.length ? `, newest ${data.session.entries.length}` : ''})
424          </Text>
425          {data.session.total === 0 ? <Text dimColor>Nothing masked yet in this session.</Text> : null}
426          {data.session.entries.map((entry, index) => (
427            <Box key={`placeholder-${index}`} flexDirection="row" gap={1} flexWrap="wrap">
428              <Text>{entry.placeholder}</Text>
429              <Text dimColor>{shown(entry.value)}</Text>
430            </Box>
431          ))}
432        </Box>
433      </Box>
434    );
435  }
436
437  if (tab === 'scan') {
438    const scan = data.scan;
439    return (
440      <Box flexDirection="column" gap={1}>
441        {header}
442        {notice}
443        {scan === null ? (
444          <Box flexDirection="column">
445            <Text>
446              Scan looks at the git identity, remotes and recent authors, this computer's name, your account name, .env
447              files and the package author; /redact-restore scan PATH checks a folder or file. Everything runs on this
448              machine, and results stay in this pane.
449            </Text>
450            <Button key="scan-run" variant="primary" onPress={() => act.scan()}>
451              Scan now
452            </Button>
453          </Box>
454        ) : (
455          <Box flexDirection="column">
456            {scan.target ? (
457              <Text dimColor>
458                {scan.target} · {scan.files ?? 0} file(s) read
459              </Text>
460            ) : null}
461            <Text dimColor>
462              {scan.candidates.length} new · {scan.covered} already masked · {scan.ignored} ignored before
463            </Text>
464            {scan.kinds && scan.kinds.length > 0 ? (
465              <Text dimColor>Already masked in these files: {tally(scan.kinds)}</Text>
466            ) : null}
467            <Box flexDirection="row" gap={1}>
468              <Button key="scan-again" onPress={() => act.scan()}>
469                Scan again
470              </Button>
471              {scan.candidates.length > 0 ? (
472                <Button key="scan-all" variant="primary" onPress={() => act.acceptAll()}>
473                  Add all
474                </Button>
475              ) : null}
476            </Box>
477            {scan.candidates.map((candidate, index) => (
478              <Box key={`candidate-${index}`} flexDirection="row" gap={1} flexWrap="wrap">
479                <Text>{shown(candidate.value)}</Text>
480                <Text dimColor>
481                  {candidate.label} · {candidate.source}
482                </Text>
483                <Button key={`accept-${index}`} onPress={() => act.accept(candidate)}>
484                  Add
485                </Button>
486                <Button key={`ignore-${index}`} onPress={() => act.ignore(candidate)}>
487                  Ignore
488                </Button>
489              </Box>
490            ))}
491          </Box>
492        )}
493      </Box>
494    );
495  }
496
497  return (
498    <Box flexDirection="column" gap={1}>
499      {header}
500      {notice}
501      {data.canType && Input && Select ? (
502        <Box key="form" flexDirection="column" borderStyle="round" paddingX={1}>
503          <Text bold>{draft.editing ? 'Edit rule' : 'Add a rule'}</Text>
504          {draft.editing ? null : (
505            <Select
506              key="draft-kind"
507              label="Rule type"
508              value={draft.kind}
509              options={[
510                { value: 'value', label: 'Exact value' },
511                { value: 'pattern', label: 'Pattern (regular expression)' },
512              ]}
513              onSelect={kind => act.setDraft({ kind: kind === 'pattern' ? 'pattern' : 'value' })}
514            />
515          )}
516          {draft.kind === 'pattern' ? (
517            <Input
518              key="draft-pattern"
519              label="Pattern"
520              placeholder="e.g. ACME-[A-Z0-9]{16}"
521              value={draft.pattern}
522              autoFocus
523              onInput={pattern => act.setDraft({ pattern })}
524              onSubmit={pattern => act.setDraft({ pattern })}
525            />
526          ) : draft.editing ? (
527            <Box flexDirection="row" gap={1}>
528              <Text>Private value:</Text>
529              <Text>{shown(draft.value)}</Text>
530              <Text dimColor>(fixed while editing; remove the rule to change it)</Text>
531            </Box>
532          ) : (
533            <Input
534              key="draft-value"
535              label="Private value"
536              placeholder="e.g. Acme Corp"
537              value={draft.value}
538              submitLabel="save"
539              autoFocus
540              onInput={value => act.setDraft({ value })}
541              onSubmit={value => act.saveDraft({ value })}
542            />
543          )}
544          {draft.kind === 'pattern' ? (
545            <Input
546              key="draft-example"
547              label="Example it must match"
548              placeholder="a made-up sample, e.g. ACME-0000AAAA1111BBBB"
549              value={draft.example}
550              submitLabel="save"
551              onInput={example => act.setDraft({ example })}
552              onSubmit={example => act.saveDraft({ example })}
553            />
554          ) : (
555            <Select
556              key="draft-mode"
557              label="Replace with"
558              value={draft.mode}
559              options={[
560                { value: 'label', label: 'Placeholder with a label' },
561                { value: 'alias', label: 'Fixed alias' },
562              ]}
563              onSelect={mode => act.setDraft({ mode: mode === 'alias' ? 'alias' : 'label' })}
564            />
565          )}
566          {draft.kind === 'pattern' || draft.mode === 'label' ? (
567            <Select
568              key="draft-label"
569              label="Label"
570              value={draft.customLabel ? OTHER_LABEL : draft.label}
571              options={[
572                ...data.labels.map(label => ({ value: label, label })),
573                { value: OTHER_LABEL, label: 'Other label…' },
574              ]}
575              onSelect={label =>
576                act.setDraft(label === OTHER_LABEL ? { customLabel: true, label: '' } : { customLabel: false, label })
577              }
578            />
579          ) : (
580            <Input
581              key="draft-alias"
582              label="Alias"
583              placeholder="e.g. CLIENT_A"
584              value={draft.alias}
585              submitLabel="save"
586              onInput={alias => act.setDraft({ alias })}
587              onSubmit={alias => act.saveDraft({ alias })}
588            />
589          )}
590          {(draft.kind === 'pattern' || draft.mode === 'label') && draft.customLabel ? (
591            <Input
592              key="draft-custom-label"
593              label="Custom label"
594              placeholder="e.g. VENDOR"
595              value={draft.label}
596              submitLabel="save"
597              onInput={label => act.setDraft({ label })}
598              onSubmit={label => act.saveDraft({ label })}
599            />
600          ) : null}
601          <Input
602            key="draft-description"
603            label="Description for the model (optional)"
604            placeholder="what it is, not the value: e.g. client company name"
605            value={draft.description}
606            submitLabel="save"
607            onInput={description => act.setDraft({ description })}
608            onSubmit={description => act.saveDraft({ description })}
609          />
610          <Select
611            key="draft-case"
612            label="Ignore case"
613            value={draft.ignoreCase}
614            options={[
615              { value: 'default', label: `Follow the option (${data.caseDefault ? 'yes' : 'no'})` },
616              { value: 'yes', label: 'Yes' },
617              { value: 'no', label: 'No' },
618            ]}
619            onSelect={ignoreCase =>
620              act.setDraft({ ignoreCase: ignoreCase === 'yes' ? 'yes' : ignoreCase === 'no' ? 'no' : 'default' })
621            }
622          />
623          {draft.kind === 'pattern' ? (
624            <Select
625              key="draft-whole"
626              label="Whole word"
627              value={draft.wholeWord ? 'yes' : 'no'}
628              options={[
629                { value: 'yes', label: 'Yes: no letter or digit touching either end' },
630                { value: 'no', label: 'No: match inside longer text too' },
631              ]}
632              onSelect={whole => act.setDraft({ wholeWord: whole !== 'no' })}
633            />
634          ) : null}
635          {draft.kind === 'pattern' ? (
636            <Select
637              key="draft-group"
638              label="Mask"
639              value={String(draft.group)}
640              options={[
641                { value: '0', label: 'The whole match' },
642                { value: '1', label: 'Capture group 1 only' },
643                { value: '2', label: 'Capture group 2 only' },
644                { value: '3', label: 'Capture group 3 only' },
645              ]}
646              onSelect={group => act.setDraft({ group: Number(group) || 0 })}
647            />
648          ) : null}
649          <Text dimColor>
650            The model sees:{' '}
651            {draft.kind === 'pattern'
652              ? patternPreview(draft, data.caseDefault, data.example)
653              : draftPreview(draft, data.example)}
654            {draft.description.trim() ? `, described as "${draft.description.trim()}"` : ''}
655          </Text>
656          <Box flexDirection="row" gap={1}>
657            <Button key="draft-save" variant="primary" onPress={() => act.saveDraft()}>
658              {draft.editing ? 'Save rule' : 'Add rule'}
659            </Button>
660            <Button key="draft-clear" onPress={() => act.clearDraft()}>
661              Clear
662            </Button>
663          </Box>
664        </Box>
665      ) : (
666        <Text dimColor>Adding rules needs the terminal or the desktop app.</Text>
667      )}
668      <Box flexDirection="column">
669        <Text bold>Saved rules ({data.stored.length}) · plugin store, takes effect now</Text>
670        {data.stored.length === 0 ? <Text dimColor>None yet. Add one above, or use Scan.</Text> : null}
671        {data.stored.map((rule, index) => (
672          <Box key={`rule-${index}`} flexDirection="row" gap={1} flexWrap="wrap">
673            <Text>{shown(rule.value)}</Text>
674            <Text dimColor>
675              {tag(rule)} · {rule.source}
676              {rule.description ? ` · "${rule.description}"` : ''}
677            </Text>
678            {data.canType ? (
679              <Button key={`edit-${index}`} onPress={() => act.edit(rule)}>
680                Edit
681              </Button>
682            ) : null}
683            <Button key={`remove-${index}`} onPress={() => act.remove(rule.value)}>
684              Remove
685            </Button>
686          </Box>
687        ))}
688      </Box>
689      <Box flexDirection="column">
690        <Text bold>Saved patterns ({data.patterns.length}) · plugin store, takes effect now</Text>
691        {data.patterns.length === 0 ? <Text dimColor>None yet. Pick Pattern as the rule type to add one.</Text> : null}
692        {data.patterns.map((pattern, index) => (
693          <Box key={`pattern-${index}`} flexDirection="row" gap={1} flexWrap="wrap">
694            <Text>/{pattern.pattern}/</Text>
695            <Text dimColor>
696              {pattern.label ?? CUSTOM_LABEL}
697              {pattern.ignoreCase ? ' · ignore case' : ''}
698              {pattern.wholeWord ? ' · whole word' : ''}
699              {pattern.group ? ` · group ${pattern.group}` : ''} · {pattern.source}
700              {pattern.description ? ` · "${pattern.description}"` : ''}
701            </Text>
702            <Button key={`remove-pattern-${index}`} onPress={() => act.removePattern(pattern)}>
703              Remove
704            </Button>
705          </Box>
706        ))}
707      </Box>
708      <Box flexDirection="column">
709        <Text bold>
710          Plugin options ({data.optionRules.length}
711          {data.optionPatterns ? ` values, ${data.optionPatterns} patterns` : ''}) · keychain, edit in /plugin
712        </Text>
713        {data.optionRules.map((rule, index) => (
714          <Box key={`option-${index}`} flexDirection="row" gap={1}>
715            <Text>{shown(rule.value)}</Text>
716            <Text dimColor>{tag(rule)}</Text>
717          </Box>
718        ))}
719      </Box>
720    </Box>
721  );
722}
723
hooks/placeholder.ts 187 lines
1/**
2 * How placeholders look. Pure: a scheme is built once from the options and
3 * passed to the matcher, the masker and the restorers.
4 *
5 * A format is literal text with `{label}` and `{id}` fields. The default,
6 * `{{PRIVATE:{label}#{id}}}` (e.g. `{{PRIVATE:EMAIL#3fa1c09e8b2d}}`), is easy for models to
7 * copy and has no shell glob or redirect characters; it is also template
8 * syntax (Jinja, Handlebars, Actions) and needs quoting in YAML, which matters
9 * only where a tool receives placeholders unrestored. A format must
10 * start with literal text and contain no whitespace, so a placeholder can be
11 * found in running text and recognized while it streams in pieces; it ends
12 * with literal text or with the fixed-length `{id}`.
13 */
14import { hmacSha256Hex } from './hmac.ts';
15import {
16  CUSTOM_LABEL,
17  DEFAULT_FORMAT,
18  DESCRIPTION_LIMITS,
19  DEFAULT_ID_LENGTH,
20  EMAIL_LABEL,
21  ID_KEY_BYTES,
22  MAX_ID_LENGTH,
23  MIN_ID_LENGTH,
24} from './defaults.ts';
25
26export { CUSTOM_LABEL, DEFAULT_FORMAT, DEFAULT_ID_LENGTH, EMAIL_LABEL, MAX_ID_LENGTH, MIN_ID_LENGTH };
27
28/** Labels a user may give a value: letters, digits, underscore. */
29export const LABEL_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
30
31export interface PlaceholderScheme {
32  readonly format: string;
33  readonly idLength: number;
34  /**
35   * A placeholder for a label. With a stable-id key and a value, the id is
36   * derived from both, so the value gets the same placeholder in every session;
37   * otherwise it is random.
38   */
39  make(label: string, value?: string): string;
40  /** Whether ids are derived from values (`placeholder_ids: stable`) and the key is loaded. */
41  readonly stableIds: boolean;
42  /** Every placeholder of this format (global regex; reset `lastIndex` yourself). */
43  readonly pattern: RegExp;
44  /** The label inside a placeholder of this format, or undefined. */
45  labelOf(token: string): string | undefined;
46  /** Where to stop emitting streamed text so a split placeholder waits whole. */
47  holdPoint(text: string): number;
48  /** An example for the model note: `{{PRIVATE:EMAIL#<id>}}`. */
49  example(label: string): string;
50}
51
52function escapeRegExp(value: string): string {
53  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54}
55
56/** Why a format cannot be used, or undefined when it can. */
57export function formatProblem(format: string): string | undefined {
58  if (format.length > 80) return 'is longer than 80 characters';
59  if (/\s/.test(format)) return 'contains whitespace';
60  if (format.split('{id}').length !== 2) return 'must contain {id} exactly once';
61  if (format.split('{label}').length > 2) return 'may contain {label} at most once';
62  if (/\{label\}\{id\}|\{id\}\{label\}/.test(format)) return 'needs literal text between {label} and {id}';
63  if (/\{[a-z]+\}/.test(format.replace(/\{(?:id|label)\}/g, ''))) return 'uses an unknown field';
64  if (!format.split(/\{(?:id|label)\}/)[0]) return 'must start with literal text, e.g. {{';
65  if (format.endsWith('{label}')) return 'must end with {id} or literal text';
66  return undefined;
67}
68
69/** A key for stable ids: 64 hex characters. */
70export function isIdKey(value: unknown): value is string {
71  return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
72}
73
74export function newIdKey(): string {
75  return randomHex(ID_KEY_BYTES * 2);
76}
77
78function randomHex(length: number): string {
79  let hex = '';
80  while (hex.length < length) hex += crypto.randomUUID().replaceAll('-', '');
81  return hex.slice(0, length);
82}
83
84/**
85 * `stableKey` returns the local key for stable ids, or undefined (not loaded,
86 * or ids are random): placeholders are then random.
87 */
88export function createScheme(
89  format = DEFAULT_FORMAT,
90  idLength = DEFAULT_ID_LENGTH,
91  stableKey?: () => string | undefined,
92): PlaceholderScheme {
93  const usable = formatProblem(format) === undefined ? format : DEFAULT_FORMAT;
94  const length = Math.min(MAX_ID_LENGTH, Math.max(MIN_ID_LENGTH, Math.round(idLength)));
95  const parts = usable.split(/(\{(?:id|label)\})/);
96  const source = parts
97    .map(part =>
98      part === '{id}'
99        ? `[a-f0-9]{${length}}`
100        : part === '{label}'
101          ? '([A-Za-z][A-Za-z0-9_]{0,31})'
102          : escapeRegExp(part),
103    )
104    .join('');
105  const whole = new RegExp(`^${source}$`);
106  const opening = parts[0] ?? '';
107  const complete = new RegExp(`^${source}`);
108  const maxLength = usable.length + 32 + length;
109
110  return {
111    format: usable,
112    idLength: length,
113    make(label, value) {
114      const key = value === undefined ? undefined : stableKey?.();
115      // The label is part of the input: one value under two labels gets two ids.
116      const id = key ? hmacSha256Hex(key, `${label}\u0000${value}`).slice(0, length) : randomHex(length);
117      return usable.replace('{label}', label).replace('{id}', id);
118    },
119    get stableIds() {
120      return stableKey?.() !== undefined;
121    },
122    pattern: new RegExp(source, 'g'),
123    labelOf(token) {
124      const match = whole.exec(token);
125      if (!match) return undefined;
126      return usable.includes('{label}') ? match[1] : CUSTOM_LABEL;
127    },
128    holdPoint(text) {
129      // Hold from the leftmost place near the end where a placeholder may have
130      // started but is not complete yet: a partial opening, or an opening whose
131      // placeholder could still grow (no whitespace, not yet a full match).
132      let hold = text.length;
133      const first = opening[0] ?? '';
134      for (let start = text.lastIndexOf(first); start !== -1; start = text.lastIndexOf(first, start - 1)) {
135        const tail = text.slice(start);
136        if (tail.length > maxLength) break;
137        const partial = opening.startsWith(tail);
138        const growing = tail.startsWith(opening) && !/\s/.test(tail) && !complete.test(tail);
139        if (partial || growing) hold = start;
140        if (start === 0) break;
141      }
142      return hold;
143    },
144    example: label => usable.replace('{label}', label).replace('{id}', '<id>'),
145  };
146}
147
148export const DEFAULT_SCHEME = createScheme();
149
150/** One rule: a value, with an optional label or a fixed alias (structured fields, never parsed from text). */
151export interface ValueRule {
152  readonly value: string;
153  readonly label?: string;
154  readonly alias?: string;
155  /** Match regardless of case; absent follows the `custom_case_insensitive` option. */
156  readonly ignoreCase?: boolean;
157  /** What the value is ("client company name"): told to the model, never the value. */
158  readonly description?: string;
159}
160
161/**
162 * Whether a description has the right shape: one line, printable, at most
163 * `DESCRIPTION_LIMITS.maxLength` characters, no braces (it must not look like a
164 * placeholder). Whether it holds its own value is checked by the rule's maker.
165 */
166export function isUsableDescription(description: string): boolean {
167  return (
168    description.length > 0 && description.length <= DESCRIPTION_LIMITS.maxLength && !/[\p{Cc}{}]/u.test(description)
169  );
170}
171
172/**
173 * An alias replaces the value verbatim, so it must not look like ordinary
174 * text: restoring would turn every genuine occurrence back into the original.
175 */
176export function isUsableAlias(alias: string, value: string): boolean {
177  return (
178    alias.length >= 4 &&
179    alias.length <= 64 &&
180    !/\s/.test(alias) &&
181    /[_\d]|[A-Z].*[A-Z]/.test(alias) &&
182    alias !== value &&
183    !alias.includes(value) &&
184    !value.includes(alias)
185  );
186}
187
hooks/rules.ts 470 lines
1/**
2 * Rules kept in the plugin store (`$.store`): values the person adds, edits or
3 * removes in the rules pane, accepts from a scan, or imports. Pure: callers
4 * read and write the store. Never log, toast or return these values to the model.
5 */
6import {
7  DESCRIPTION_HINT,
8  EXPORT_FORMAT,
9  EXPORT_VERSION,
10  IGNORED_PREFIX,
11  MIN_CUSTOM_VALUE_LENGTH,
12  PATTERN_PREFIX,
13  PREVIEW_DOTS,
14  RULE_PREFIX,
15} from './defaults.ts';
16import { aliasOverlaps } from './matcher.ts';
17import { patternKey, patternProblem, type PatternProblem, type PatternRule } from './patterns.ts';
18import { CUSTOM_LABEL, isUsableAlias, isUsableDescription, LABEL_PATTERN, type ValueRule } from './placeholder.ts';
19
20export { EXPORT_FORMAT, EXPORT_VERSION, IGNORED_PREFIX, PATTERN_PREFIX, RULE_PREFIX };
21
22export const RULE_SOURCES = ['manual', 'scan', 'import'] as const;
23export type RuleSource = (typeof RULE_SOURCES)[number];
24
25export interface StoredRule {
26  readonly value: string;
27  readonly label?: string;
28  readonly alias?: string;
29  /** Match regardless of case; absent follows the `custom_case_insensitive` option. */
30  readonly ignoreCase?: boolean;
31  /** What the value is, told to the model instead of the value. */
32  readonly description?: string;
33  readonly source: RuleSource;
34  /** ISO time the rule was first added; it orders the list. */
35  readonly addedAt: string;
36}
37
38/** A pattern rule kept in the plugin store. Its example is checked on save and never stored. */
39export interface StoredPattern extends Omit<PatternRule, 'example'> {
40  readonly source: RuleSource;
41  readonly addedAt: string;
42}
43
44export type RuleProblem =
45  | 'empty'
46  | 'too-short'
47  | 'no-label'
48  | 'invalid-label'
49  | 'no-alias'
50  | 'invalid-alias'
51  | 'alias-in-use'
52  | 'alias-overlap'
53  | 'invalid-description'
54  | 'description-has-value';
55
56export type RuleParse = { readonly rule: StoredRule } | { readonly problem: RuleProblem };
57
58/**
59 * Rules are one per exact value: with case-sensitive matching (the default),
60 * `Acme` and `ACME` are two values, each masked on its own.
61 */
62export function ruleKey(value: string): string {
63  return value;
64}
65
66/** Checks a rule against the matcher's limits and the aliases already in use. */
67export function makeRule(
68  input: ValueRule,
69  source: RuleSource,
70  now: Date,
71  existing: readonly ValueRule[] = [],
72): RuleParse {
73  const value = input.value.trim();
74  if (value.length === 0) return { problem: 'empty' };
75  if (value.length < MIN_CUSTOM_VALUE_LENGTH) return { problem: 'too-short' };
76  const label = input.label?.trim().toUpperCase();
77  const alias = input.alias?.trim();
78  if (label !== undefined && !LABEL_PATTERN.test(label)) return { problem: 'invalid-label' };
79  if (alias !== undefined && !isUsableAlias(alias, value)) return { problem: alias ? 'invalid-alias' : 'no-alias' };
80  const description = input.description?.trim() || undefined;
81  if (description !== undefined && !isUsableDescription(description)) return { problem: 'invalid-description' };
82  // The description goes to the model: it must not hold the value it describes.
83  if (description?.toLowerCase().includes(value.toLowerCase())) return { problem: 'description-has-value' };
84  const others = existing.filter(rule => ruleKey(rule.value) !== ruleKey(value));
85  if (alias && others.some(rule => rule.alias === alias)) return { problem: 'alias-in-use' };
86  // An alias is restored wherever it appears: it must not overlap any other
87  // value, and this value must not overlap another rule's alias.
88  if (
89    alias &&
90    aliasOverlaps(
91      alias,
92      value,
93      others.map(rule => rule.value),
94    )
95  )
96    return { problem: 'alias-overlap' };
97  const otherAliases = others.flatMap(rule => (rule.alias ? [rule.alias] : []));
98  if (otherAliases.some(other => aliasOverlaps(other, '', [value]))) return { problem: 'alias-overlap' };
99  return {
100    rule: {
101      value,
102      ...(alias ? { alias } : label ? { label } : {}),
103      ...(input.ignoreCase !== undefined ? { ignoreCase: input.ignoreCase } : {}),
104      ...(description ? { description } : {}),
105      source,
106      addedAt: now.toISOString(),
107    },
108  };
109}
110
111export function describeProblem(problem: RuleProblem): string {
112  switch (problem) {
113    case 'empty':
114      return 'Type a value first.';
115    case 'too-short':
116      return `Values need at least ${MIN_CUSTOM_VALUE_LENGTH} characters.`;
117    case 'no-label':
118      return 'Type a label, or pick one from the list.';
119    case 'invalid-label':
120      return 'A label is a letter, then letters, digits or _ (up to 32 characters).';
121    case 'no-alias':
122      return 'Type an alias, such as CLIENT_A.';
123    case 'invalid-alias':
124      return 'An alias needs 4 to 64 characters, no spaces, an underscore, a digit or two capitals, and must not contain the value.';
125    case 'alias-in-use':
126      return 'That alias already stands for another value.';
127    case 'alias-overlap':
128      return "That alias overlaps another rule's value, or this value overlaps another rule's alias.";
129    case 'invalid-description':
130      return DESCRIPTION_HINT;
131    case 'description-has-value':
132      return 'The description contains the value itself: describe what it is without writing it.';
133  }
134}
135
136function isRecord(value: unknown): value is Record<string, unknown> {
137  return typeof value === 'object' && value !== null && !Array.isArray(value);
138}
139
140function valueRuleOf(item: Record<string, unknown>): ValueRule | undefined {
141  if (typeof item.value !== 'string') return undefined;
142  return {
143    value: item.value,
144    ...(typeof item.label === 'string' ? { label: item.label } : {}),
145    ...(typeof item.alias === 'string' ? { alias: item.alias } : {}),
146    ...(typeof item.ignoreCase === 'boolean' ? { ignoreCase: item.ignoreCase } : {}),
147    ...(typeof item.description === 'string' ? { description: item.description } : {}),
148  };
149}
150
151function patternRuleOf(item: Record<string, unknown>): PatternRule | undefined {
152  if (typeof item.pattern !== 'string') return undefined;
153  return {
154    pattern: item.pattern,
155    ...(typeof item.label === 'string' ? { label: item.label.toUpperCase() } : {}),
156    ignoreCase: item.ignoreCase === true,
157    wholeWord: item.wholeWord !== false,
158    group: typeof item.group === 'number' && Number.isInteger(item.group) ? item.group : 0,
159    ...(typeof item.description === 'string' ? { description: item.description } : {}),
160  };
161}
162
163// Checking a pattern runs probes; remember results so loading the store on every prompt stays cheap.
164const checked = new Map<string, PatternProblem | 'ok'>();
165function checkOnce(rule: PatternRule): PatternProblem | undefined {
166  const key = `${patternKey(rule)}\u0002${rule.label ?? ''}\u0002${rule.description ?? ''}`;
167  let result = checked.get(key);
168  if (result === undefined) {
169    result = patternProblem({ ...rule, example: undefined }) ?? 'ok';
170    if (checked.size > 1000) checked.clear();
171    checked.set(key, result);
172  }
173  return result === 'ok' ? undefined : result;
174}
175
176/** A pattern rule from the form or an import, checked; the example (if any) must match. */
177export function makePattern(
178  input: PatternRule,
179  source: RuleSource,
180  now: Date,
181): { pattern: StoredPattern } | { problem: PatternProblem } {
182  const { description: rawDescription, ...rest } = input;
183  const description = rawDescription?.trim() || undefined;
184  const rule: PatternRule = {
185    ...rest,
186    pattern: input.pattern.trim(),
187    ...(input.label ? { label: input.label.trim().toUpperCase() } : {}),
188    ...(description ? { description } : {}),
189  };
190  const problem = input.example !== undefined ? patternProblem(rule) : checkOnce(rule);
191  if (problem) return { problem };
192  const { example: _example, ...kept } = rule;
193  return {
194    pattern: {
195      ...kept,
196      ...(kept.label === CUSTOM_LABEL ? { label: undefined } : {}),
197      source,
198      addedAt: now.toISOString(),
199    },
200  };
201}
202
203/** Reads stored pattern rules, dropping any that are malformed or fail the checks. */
204export function parseStoredPatterns(raw: unknown): StoredPattern[] {
205  if (!Array.isArray(raw)) return [];
206  const patterns: StoredPattern[] = [];
207  for (const item of raw) {
208    if (!isRecord(item)) continue;
209    const rule = patternRuleOf(item);
210    if (!rule || checkOnce(rule)) continue;
211    if (patterns.some(one => patternKey(one) === patternKey(rule))) continue;
212    patterns.push({
213      ...rule,
214      source: RULE_SOURCES.find(one => one === item.source) ?? 'manual',
215      addedAt: typeof item.addedAt === 'string' ? item.addedAt : new Date(0).toISOString(),
216    });
217  }
218  return patterns.sort((a, b) => a.addedAt.localeCompare(b.addedAt) || a.pattern.localeCompare(b.pattern));
219}
220
221/** Adds a pattern, or updates the label of the one with the same source and flags (keeping its place). */
222export function upsertPattern(
223  list: readonly StoredPattern[],
224  pattern: StoredPattern,
225): { patterns: StoredPattern[]; change: 'added' | 'updated' | 'unchanged' } {
226  const index = list.findIndex(one => patternKey(one) === patternKey(pattern));
227  if (index === -1) return { patterns: [...list, pattern], change: 'added' };
228  const current = list[index] as StoredPattern;
229  if (
230    (current.label ?? CUSTOM_LABEL) === (pattern.label ?? CUSTOM_LABEL) &&
231    current.description === pattern.description
232  )
233    return { patterns: [...list], change: 'unchanged' };
234  const updated = { ...pattern, addedAt: current.addedAt };
235  return { patterns: list.map((one, i) => (i === index ? updated : one)), change: 'updated' };
236}
237
238/** Identity of a pattern list, for change detection and caches. */
239export function patternsKey(list: readonly StoredPattern[]): string {
240  return JSON.stringify(list.map(one => [patternKey(one), one.label ?? '', one.description ?? '']));
241}
242
243/** Reads the stored list, dropping anything malformed (the store is a local file a person may edit). */
244export function parseStoredRules(raw: unknown): StoredRule[] {
245  if (!Array.isArray(raw)) return [];
246  const rules: StoredRule[] = [];
247  for (const item of raw) {
248    const input = isRecord(item) ? valueRuleOf(item) : undefined;
249    if (!input || !isRecord(item)) continue;
250    const source = RULE_SOURCES.find(one => one === item.source) ?? 'manual';
251    const parsed = makeRule(input, source, new Date(0), rules);
252    if (!('rule' in parsed) || rules.some(rule => ruleKey(rule.value) === ruleKey(parsed.rule.value))) continue;
253    rules.push({ ...parsed.rule, addedAt: typeof item.addedAt === 'string' ? item.addedAt : parsed.rule.addedAt });
254  }
255  return rules;
256}
257
258/** Adds a rule, or replaces the one for the same value (a new label or alias). */
259export function upsertRule(
260  rules: readonly StoredRule[],
261  rule: StoredRule,
262): { rules: StoredRule[]; change: 'added' | 'updated' | 'unchanged' } {
263  const key = ruleKey(rule.value);
264  const index = rules.findIndex(one => ruleKey(one.value) === key);
265  if (index === -1) return { rules: [...rules, rule], change: 'added' };
266  const current = rules[index] as StoredRule;
267  if (
268    current.value === rule.value &&
269    current.label === rule.label &&
270    current.alias === rule.alias &&
271    current.ignoreCase === rule.ignoreCase &&
272    current.description === rule.description
273  )
274    return { rules: [...rules], change: 'unchanged' };
275  // An update keeps the rule's place: its added time stays.
276  const updated = { ...rule, addedAt: current.addedAt };
277  return { rules: rules.map((one, i) => (i === index ? updated : one)), change: 'updated' };
278}
279
280export function removeRule(rules: readonly StoredRule[], value: string): StoredRule[] {
281  const key = ruleKey(value);
282  return rules.filter(rule => ruleKey(rule.value) !== key);
283}
284
285/**
286 * Rules for matching: the plugin options (keychain) first, so an option's
287 * label or alias wins over a stored rule for the same value.
288 */
289export function mergedRules(optionRules: readonly ValueRule[], stored: readonly StoredRule[]): ValueRule[] {
290  return [...optionRules, ...stored];
291}
292
293/** A short form for the pane: the first character, dots and the length. */
294export function maskPreview(value: string): string {
295  const first = Array.from(value)[0] ?? '';
296  return `${first}${'•'.repeat(PREVIEW_DOTS)} (${Array.from(value).length})`;
297}
298
299/** The export file: originals in plain text, so the caller warns where it is written. */
300export function exportRules(rules: readonly StoredRule[], now: Date, patterns: readonly StoredPattern[] = []): string {
301  return `${JSON.stringify(
302    {
303      format: EXPORT_FORMAT,
304      version: EXPORT_VERSION,
305      exportedAt: now.toISOString(),
306      rules: rules.map(rule => ({
307        value: rule.value,
308        ...(rule.label ? { label: rule.label } : {}),
309        ...(rule.alias ? { alias: rule.alias } : {}),
310        ...(rule.ignoreCase !== undefined ? { ignoreCase: rule.ignoreCase } : {}),
311        ...(rule.description ? { description: rule.description } : {}),
312      })),
313      patterns: patterns.map(one => ({
314        pattern: one.pattern,
315        ...(one.label ? { label: one.label } : {}),
316        ignoreCase: one.ignoreCase,
317        wholeWord: one.wholeWord,
318        group: one.group,
319        ...(one.description ? { description: one.description } : {}),
320      })),
321    },
322    null,
323    2,
324  )}\n`;
325}
326
327/** An import file is an export: JSON whose rules have `value`, and optionally `label` or `alias`. */
328/** `patterns` is absent when the file has no patterns list (an older export): saved patterns then stay. */
329export function parseImport(text: string): { items: ValueRule[]; patterns?: PatternRule[] } | { error: string } {
330  let data: unknown;
331  try {
332    data = JSON.parse(text);
333  } catch {
334    return { error: `The file is not a ${EXPORT_FORMAT} export (JSON).` };
335  }
336  if (!isRecord(data) || data.format !== EXPORT_FORMAT || !Array.isArray(data.rules)) {
337    return { error: `The file is not a ${EXPORT_FORMAT} export.` };
338  }
339  if (typeof data.version !== 'number' || data.version > EXPORT_VERSION) {
340    return { error: 'The file comes from a newer Redact Restore version.' };
341  }
342  // An entry without a string value is kept as an empty value, so it counts as invalid.
343  const items = data.rules.map(item => (isRecord(item) ? (valueRuleOf(item) ?? { value: '' }) : { value: '' }));
344  if (!Array.isArray(data.patterns)) return { items };
345  return {
346    items,
347    // A pattern entry without a string pattern is kept empty, so it counts as invalid.
348    patterns: data.patterns.map(item => (isRecord(item) ? (patternRuleOf(item) ?? EMPTY_PATTERN) : EMPTY_PATTERN)),
349  };
350}
351
352const EMPTY_PATTERN: PatternRule = { pattern: '', ignoreCase: false, wholeWord: true, group: 0 };
353
354/** Merges imported pattern rules into the stored ones, or replaces them all. */
355export function applyPatternImport(
356  existing: readonly StoredPattern[],
357  items: readonly PatternRule[],
358  mode: 'merge' | 'replace',
359  now: Date,
360): { patterns: StoredPattern[]; added: number; updated: number; unchanged: number; invalid: number } {
361  let patterns: StoredPattern[] = mode === 'replace' ? [] : [...existing];
362  let added = 0;
363  let updated = 0;
364  let unchanged = 0;
365  let invalid = 0;
366  for (const item of items) {
367    const made = makePattern(item, 'import', now);
368    if (!('pattern' in made)) {
369      invalid += 1;
370      continue;
371    }
372    const result = upsertPattern(patterns, made.pattern);
373    patterns = result.patterns;
374    if (result.change === 'added') added += 1;
375    else if (result.change === 'updated') updated += 1;
376    else unchanged += 1;
377  }
378  return { patterns, added, updated, unchanged, invalid };
379}
380
381export interface ImportSummary {
382  readonly rules: StoredRule[];
383  readonly added: number;
384  readonly updated: number;
385  readonly unchanged: number;
386  readonly invalid: number;
387}
388
389/** Merges imported entries into the stored rules, or replaces them all. */
390export function applyImport(
391  existing: readonly StoredRule[],
392  items: readonly ValueRule[],
393  mode: 'merge' | 'replace',
394  now: Date,
395  /** Rules kept elsewhere (the plugin options) that aliases must not clash with. */
396  others: readonly ValueRule[] = [],
397): ImportSummary {
398  let rules: StoredRule[] = mode === 'replace' ? [] : [...existing];
399  let added = 0;
400  let updated = 0;
401  let unchanged = 0;
402  let invalid = 0;
403  for (const item of items) {
404    const parsed = makeRule(item, 'import', now, [...others, ...rules]);
405    if (!('rule' in parsed)) {
406      invalid += 1;
407      continue;
408    }
409    const result = upsertRule(rules, parsed.rule);
410    rules = result.rules;
411    if (result.change === 'added') added += 1;
412    else if (result.change === 'updated') updated += 1;
413    else unchanged += 1;
414  }
415  return { rules, added, updated, unchanged, invalid };
416}
417
418/** SHA-256 hex of a value: how dismissed scan candidates are remembered without keeping them. */
419export async function fingerprint(value: string): Promise<string> {
420  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ruleKey(value)));
421  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
422}
423
424/** The store key of a rule or a dismissed candidate: a prefix and the value's fingerprint. */
425export async function storeKeyFor(prefix: string, value: string): Promise<string> {
426  return `${prefix}${await fingerprint(value)}`;
427}
428
429export function isPatternKey(key: string): boolean {
430  return key.startsWith(PATTERN_PREFIX) && /^[a-f0-9]{64}$/.test(key.slice(PATTERN_PREFIX.length));
431}
432
433export function isRuleKey(key: string): boolean {
434  return key.startsWith(RULE_PREFIX) && /^[a-f0-9]{64}$/.test(key.slice(RULE_PREFIX.length));
435}
436
437/** Oldest first, then by value, so every session lists the rules in one order. */
438export function sortRules(rules: readonly StoredRule[]): StoredRule[] {
439  return [...rules].sort((a, b) => a.addedAt.localeCompare(b.addedAt) || a.value.localeCompare(b.value));
440}
441
442/** Identity of a rule list, for change detection and caches. */
443export function rulesKey(rules: readonly StoredRule[]): string {
444  return JSON.stringify(
445    rules.map(rule => [
446      rule.value,
447      rule.label ?? '',
448      rule.alias ?? '',
449      rule.ignoreCase ?? '',
450      rule.description ?? '',
451      rule.source,
452    ]),
453  );
454}
455
456/** `~/…` for a path under home, so command output never spells the home folder (any case or separator). */
457export function displayPath(path: string, home: string | undefined): string {
458  if (!home) return path;
459  const fold = (text: string) => text.replace(/\\/g, '/').toLowerCase();
460  const base = fold(home).replace(/\/+$/, '');
461  const target = fold(path);
462  return target === base || target.startsWith(`${base}/`) ? `~${path.slice(home.replace(/[\\/]+$/, '').length)}` : path;
463}
464
465/** Expands a leading `~` against home; leaves every other path as typed. */
466export function expandHome(path: string, home: string | undefined): string {
467  if (!home || !(path === '~' || path.startsWith('~/') || path.startsWith('~\\'))) return path;
468  return `${home.replace(/[\\/]+$/, '')}${path.slice(1)}`;
469}
470
hooks/patterns.ts 312 lines
1/**
2 * Pattern rules: a user's own regular expression, as structured fields (never
3 * parsed from an entry string). Pure.
4 *
5 * JavaScript cannot time out a regex, and timing probes are neither reliable nor
6 * able to catch quadratic slowdowns, so a pattern must fall inside a safe subset,
7 * checked deterministically:
8 * - quantifiers only on single atoms (a character, an escape, a class), never on
9 *   a group, which rules out exponential backtracking;
10 * - at most two free repeats (`*`, `+`, `{n,}`);
11 * - no backreferences and no lookarounds;
12 * - a fixed text of 3+ characters every match contains (the keyword).
13 * At run time the regex runs only in a window around each keyword occurrence
14 * (as gitleaks does), so even a quadratic pattern costs a bounded amount per hit.
15 */
16import {
17  CUSTOM_LABEL,
18  KEYWORD_RESCAN_LIMIT,
19  KEYWORD_WINDOW_AFTER,
20  KEYWORD_WINDOW_BEFORE,
21  MIN_CUSTOM_VALUE_LENGTH,
22  PATTERN_LIMITS,
23  DESCRIPTION_HINT,
24} from './defaults.ts';
25import type { Detector, Span } from './detectors.ts';
26import { LABEL_PATTERN, isUsableDescription } from './placeholder.ts';
27
28export interface PatternRule {
29  /** The regex source, without slashes or flags. */
30  readonly pattern: string;
31  readonly label?: string;
32  readonly ignoreCase: boolean;
33  /** Only where no letter or digit touches either end (GitHub's default boundary). */
34  readonly wholeWord: boolean;
35  /** 0 masks the whole match; N masks capture group N only. */
36  readonly group: number;
37  /** A sample that must match when the rule is saved; never sent anywhere. */
38  readonly example?: string;
39  /** What a match is ("internal API token"): told to the model; must not match the pattern. */
40  readonly description?: string;
41}
42
43export type PatternProblem =
44  | 'empty'
45  | 'too-long'
46  | 'invalid'
47  | 'unbalanced'
48  | 'backreference'
49  | 'lookaround'
50  | 'group-quantifier'
51  | 'too-many-repeats'
52  | 'no-keyword'
53  | 'no-group'
54  | 'invalid-label'
55  | 'no-example'
56  | 'example-no-match'
57  | 'example-too-short'
58  | 'invalid-description'
59  | 'description-has-value';
60
61/** What the scanner learns about a source: a problem, or its keyword and capture groups. */
62interface Shape {
63  readonly problem?: PatternProblem;
64  readonly keyword?: string;
65  readonly groups: number;
66}
67
68const QUANTIFIER = /^(?:[*+?]|\{\d+(?:,\d*)?\})/;
69const LITERAL_CHAR = /[A-Za-z0-9_\-:#@/%=~!<>"' ]/;
70const LOOKAROUNDS = ['(?=', '(?!', '(?<=', '(?<!'];
71
72/**
73 * Walks the source once. Escapes and character classes are single atoms; a
74 * quantifier may follow only an atom; the longest run of plain characters at the
75 * top level (outside any alternation, not made optional or repeated) is the keyword.
76 */
77function analyze(source: string): Shape {
78  let depth = 0;
79  let groups = 0;
80  let freeRepeats = 0;
81  let topAlternation = false;
82  let run = '';
83  let best = '';
84  const endRun = () => {
85    if (run.trim().length > best.trim().length) best = run;
86    run = '';
87  };
88  let i = 0;
89  while (i < source.length) {
90    const ch = source.charAt(i);
91    if (ch === '(') {
92      endRun();
93      if (LOOKAROUNDS.some(open => source.startsWith(open, i))) return { problem: 'lookaround', groups };
94      depth += 1;
95      if (source.startsWith('(?:', i)) {
96        i += 3;
97      } else if (source.startsWith('(?<', i)) {
98        groups += 1;
99        const close = source.indexOf('>', i);
100        i = close > 0 ? close + 1 : i + 3;
101      } else {
102        groups += 1;
103        i += 1;
104      }
105      continue;
106    }
107    if (ch === ')') {
108      endRun();
109      if (depth === 0) return { problem: 'unbalanced', groups };
110      depth -= 1;
111      i += 1;
112      if (QUANTIFIER.test(source.slice(i))) return { problem: 'group-quantifier', groups };
113      continue;
114    }
115    if (ch === '|') {
116      endRun();
117      if (depth === 0) topAlternation = true;
118      i += 1;
119      continue;
120    }
121    let atom: string;
122    let literal: string | undefined;
123    if (ch === '\\') {
124      const next = source.charAt(i + 1);
125      if (/[1-9]/.test(next) || next === 'k') return { problem: 'backreference', groups };
126      atom = source.slice(i, i + 2);
127      // An escaped punctuation character is a literal; \d, \w, \s, \b … are not.
128      if (/[^A-Za-z0-9]/.test(next)) literal = next;
129    } else if (ch === '[') {
130      let end = i + 1;
131      while (end < source.length && source.charAt(end) !== ']') end += source.charAt(end) === '\\' ? 2 : 1;
132      atom = source.slice(i, end + 1);
133    } else {
134      atom = ch;
135      if (LITERAL_CHAR.test(ch)) literal = ch;
136    }
137    i += atom.length;
138    const quantifier = QUANTIFIER.exec(source.slice(i))?.[0];
139    if (quantifier) {
140      i += quantifier.length;
141      if (source.charAt(i) === '?') i += 1;
142      if (quantifier === '*' || quantifier === '+' || quantifier.endsWith(',}')) freeRepeats += 1;
143      if (freeRepeats > PATTERN_LIMITS.maxFreeRepeats) return { problem: 'too-many-repeats', groups };
144      endRun();
145      continue;
146    }
147    if (literal !== undefined && depth === 0) run += literal;
148    else endRun();
149  }
150  endRun();
151  if (depth !== 0) return { problem: 'unbalanced', groups };
152  const keyword = topAlternation ? '' : best.trim();
153  return keyword.length >= PATTERN_LIMITS.minKeyword ? { keyword, groups } : { problem: 'no-keyword', groups };
154}
155
156/** The rule as a global regex with match indices: whole-word guards added, the user's own flags never accepted. */
157export function compilePattern(rule: PatternRule): RegExp {
158  const source = rule.wholeWord ? `(?<![A-Za-z0-9])(?:${rule.pattern})(?![A-Za-z0-9])` : rule.pattern;
159  return new RegExp(source, rule.ignoreCase ? 'dgiu' : 'dgu');
160}
161
162/** The span a match masks: the whole match, or its capture group, at the group's own position. */
163function maskedSpan(match: RegExpExecArray, group: number, offset: number): { start: number; end: number } | undefined {
164  const range = match.indices?.[group];
165  if (!range) return undefined;
166  return { start: offset + range[0], end: offset + range[1] };
167}
168
169/** Windows around each keyword occurrence, merged; the regex runs only there. */
170function keywordWindows(text: string, keyword: string, ignoreCase: boolean): Span[] {
171  const haystack = ignoreCase ? text.toLowerCase() : text;
172  const needle = ignoreCase ? keyword.toLowerCase() : keyword;
173  const windows: Array<{ start: number; end: number }> = [];
174  for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) {
175    const window = {
176      start: Math.max(0, at - KEYWORD_WINDOW_BEFORE),
177      end: Math.min(text.length, at + needle.length + KEYWORD_WINDOW_AFTER),
178    };
179    const last = windows[windows.length - 1];
180    if (last && window.start <= last.end) last.end = Math.max(last.end, window.end);
181    else windows.push(window);
182  }
183  return windows;
184}
185
186/** Every span the rule masks in a text: matches found in keyword windows, a match at a window's edge rescanned. */
187function findSpans(rule: PatternRule, regex: RegExp, keyword: string, text: string): Span[] {
188  const spans: Span[] = [];
189  let sticky: RegExp | undefined;
190  for (const window of keywordWindows(text, keyword, rule.ignoreCase)) {
191    const slice = text.slice(window.start, window.end);
192    regex.lastIndex = 0;
193    for (let match = regex.exec(slice); match; match = regex.exec(slice)) {
194      let found: RegExpExecArray = match;
195      let offset = window.start;
196      if (match.index + match[0].length >= slice.length && window.end < text.length) {
197        sticky ??= new RegExp(regex.source, regex.flags.replace('g', 'y'));
198        sticky.lastIndex = window.start + match.index;
199        const longer = sticky.exec(text.slice(0, Math.min(text.length, sticky.lastIndex + KEYWORD_RESCAN_LIMIT)));
200        if (longer) {
201          found = longer;
202          offset = 0;
203        }
204      }
205      const span = maskedSpan(found, rule.group, offset);
206      // A match that starts at the window's left edge may have begun earlier:
207      // widen the mask over the run of non-space characters it sits in (fail safe).
208      if (span && offset === window.start && span.start === window.start && window.start > 0) {
209        let start = span.start;
210        const limit = Math.max(0, span.start - KEYWORD_RESCAN_LIMIT);
211        while (start > limit && !/\s/.test(text.charAt(start - 1))) start -= 1;
212        span.start = start;
213      }
214      if (span && span.end - span.start >= MIN_CUSTOM_VALUE_LENGTH) spans.push(span);
215      if (match[0].length === 0) regex.lastIndex += 1;
216    }
217  }
218  return spans;
219}
220
221/** What is wrong with a rule, or undefined when it may be saved. Deterministic: no timing. */
222export function patternProblem(rule: PatternRule): PatternProblem | undefined {
223  const source = rule.pattern;
224  if (!source.trim()) return 'empty';
225  if (source.length > PATTERN_LIMITS.maxLength) return 'too-long';
226  if (rule.label !== undefined && !LABEL_PATTERN.test(rule.label)) return 'invalid-label';
227  try {
228    // On its own first: a stray `)` must not hide behind the whole-word wrapper.
229    new RegExp(source, 'u');
230    compilePattern(rule);
231  } catch {
232    return 'invalid';
233  }
234  const shape = analyze(source);
235  if (shape.problem) return shape.problem;
236  if (rule.group < 0 || rule.group > shape.groups) return 'no-group';
237  if (rule.description !== undefined) {
238    if (!isUsableDescription(rule.description)) return 'invalid-description';
239    // The description goes to the model: it must not itself hold a value the rule masks.
240    if (findSpans(rule, compilePattern(rule), shape.keyword ?? '', rule.description).length > 0)
241      return 'description-has-value';
242  }
243  if (rule.example === undefined) return undefined;
244  if (!rule.example.trim()) return 'no-example';
245  if (findSpans(rule, compilePattern(rule), shape.keyword ?? '', rule.example).length > 0) return undefined;
246  return compilePattern(rule).exec(rule.example) ? 'example-too-short' : 'example-no-match';
247}
248
249/** The keyword a valid pattern is found by, for the form to show. */
250export function patternKeyword(pattern: string): string | undefined {
251  return analyze(pattern).keyword;
252}
253
254export function describePatternProblem(problem: PatternProblem): string {
255  switch (problem) {
256    case 'empty':
257      return 'Type a pattern first.';
258    case 'too-long':
259      return `A pattern can be at most ${PATTERN_LIMITS.maxLength} characters.`;
260    case 'invalid':
261      return 'That is not a valid regular expression (patterns use the u flag: escape only characters that need it, so write - rather than \\-).';
262    case 'unbalanced':
263      return 'The parentheses do not balance.';
264    case 'backreference':
265      return 'Backreferences (\\1, \\k<name>) are not allowed.';
266    case 'lookaround':
267      return 'Lookarounds such as (?=…) and (?<!…) are not allowed; use Whole word for boundaries.';
268    case 'group-quantifier':
269      return 'Repeat single characters or classes, not groups: write [a-z0-9.-]+ rather than ([a-z0-9-]+\\.)+.';
270    case 'too-many-repeats':
271      return `Use at most ${PATTERN_LIMITS.maxFreeRepeats} open-ended repeats (*, +, {n,}); give the others a size such as {1,40}.`;
272    case 'no-keyword':
273      return `The pattern needs a fixed text of at least ${PATTERN_LIMITS.minKeyword} characters that every match contains, such as ACME- (not inside a group or a | alternative).`;
274    case 'no-group':
275      return 'The pattern has no capture group with that number.';
276    case 'invalid-label':
277      return 'A label is a letter, then letters, digits or _ (up to 32 characters).';
278    case 'no-example':
279      return 'Type an example that the pattern should match.';
280    case 'example-no-match':
281      return 'The pattern does not match the example.';
282    case 'example-too-short':
283      return `The part the pattern masks in the example must be at least ${MIN_CUSTOM_VALUE_LENGTH} characters.`;
284    case 'invalid-description':
285      return DESCRIPTION_HINT;
286    case 'description-has-value':
287      return 'The description matches the pattern itself: describe what the value is without writing one.';
288  }
289}
290
291/** A detector for the matching code: spans of each match (or its group), under the rule's label. */
292export function patternDetector(rule: PatternRule, id: string): Detector {
293  const regex = compilePattern(rule);
294  const keyword = analyze(rule.pattern).keyword ?? '';
295  const label = rule.label ?? CUSTOM_LABEL;
296  return {
297    id,
298    label,
299    groups: [],
300    defaultOn: true,
301    // Same standing as exact values: a user's own rule beats the built-in detectors.
302    rank: 0,
303    ...(rule.description ? { description: rule.description } : {}),
304    find: text => (keyword ? findSpans(rule, regex, keyword, text) : []),
305  };
306}
307
308/** A stable identity for a rule: its source and the flags that change what it matches. */
309export function patternKey(rule: Pick<PatternRule, 'pattern' | 'ignoreCase' | 'wholeWord' | 'group'>): string {
310  return `${rule.pattern}\u0001${rule.ignoreCase ? 'i' : ''}${rule.wholeWord ? 'w' : ''}${rule.group}`;
311}
312
hooks/scan.ts 259 lines
1/**
2 * Finds likely private values on this machine and in this folder: the git
3 * identity, remotes and recent authors, the host and user names, `.env` values
4 * and the package author. Pure: the caller gathers the inputs (git, files) and
5 * shows candidates only in the local pane. Never send a candidate's value to the model.
6 */
7import {
8  BOT_AUTHOR,
9  ENV_EXAMPLE,
10  ENV_FILE,
11  GENERIC_ACCOUNTS,
12  LOCAL_HOST_SUFFIX,
13  MIN_CUSTOM_VALUE_LENGTH,
14  PUBLIC_GIT_HOSTS,
15  SECRET_KEY,
16  TRIVIAL_VALUE,
17  SCAN_DESCRIPTIONS,
18} from './defaults.ts';
19import { envAssignments, findInternalHosts } from './detectors.ts';
20import { findPrivateMatches, type MatchingOptions } from './matcher.ts';
21import { fingerprint, ruleKey } from './rules.ts';
22
23export interface ScanCandidate {
24  readonly value: string;
25  /** The label a rule for it gets: NAME, EMAIL, ORG, HOST, PROJECT, USER, SECRET or ENV. */
26  readonly label: string;
27  /** Where it was found, without the value (`git config user.name`, `.env (DB_PASSWORD)`). */
28  readonly source: string;
29  /** What it is, for the rule's description (told to the model, never the value). */
30  readonly description?: string;
31}
32
33export interface ScanInputs {
34  readonly gitName?: string;
35  readonly gitEmail?: string;
36  /** `git remote -v` output. */
37  readonly remotes?: string;
38  /** `git log --format=%an%x00%ae` output. */
39  readonly logAuthors?: string;
40  readonly hostname?: string;
41  /** The home folder path; its last segment is the account name. */
42  readonly home?: string;
43  readonly envFiles?: ReadonlyArray<{ readonly name: string; readonly text: string }>;
44  /** package.json text. */
45  readonly packageJson?: string;
46}
47
48export { PUBLIC_GIT_HOSTS };
49
50/** Whether a root-level file name is a real `.env` file (not an example). */
51export function isEnvFile(name: string): boolean {
52  return ENV_FILE.test(name) && !ENV_EXAMPLE.test(name);
53}
54
55/** `KEY=value` pairs of a dotenv file: `export`, quotes and trailing comments handled. */
56export function parseEnvFile(text: string): Array<{ key: string; value: string }> {
57  const pairs: Array<{ key: string; value: string }> = [];
58  for (const line of text.split(/\r?\n/)) {
59    const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/.exec(line);
60    if (!match) continue;
61    const key = match[1] ?? '';
62    let value = (match[2] ?? '').trim();
63    const quote = value.charAt(0);
64    if ((quote === '"' || quote === "'" || quote === '`') && value.indexOf(quote, 1) > 0) {
65      value = value.slice(1, value.indexOf(quote, 1));
66    } else {
67      value = value.replace(/\s+#.*$/, '').trim();
68    }
69    pairs.push({ key, value });
70  }
71  return pairs;
72}
73
74/**
75 * Host, owner and repository of each remote URL (https, ssh, scp-like):
76 * the owner is the first path segment, the repository the last, so GitLab
77 * subgroups and Azure DevOps (`v3/org/project/repo`, `org/project/_git/repo`) parse.
78 */
79export function parseRemotes(text: string): Array<{ host: string; owner: string; repo: string }> {
80  const found: Array<{ host: string; owner: string; repo: string }> = [];
81  for (const line of text.split(/\r?\n/)) {
82    const url = line.split(/\s+/)[1];
83    if (!url) continue;
84    const match = /^(?:[a-z+]+:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/i.exec(url);
85    if (!match) continue;
86    const segments = (match[2] ?? '')
87      .split('/')
88      .filter(segment => segment && segment !== '_git' && !/^v\d+$/.test(segment));
89    if (segments.length < 2) continue;
90    found.push({
91      host: (match[1] ?? '').toLowerCase(),
92      owner: segments[0] ?? '',
93      repo: segments[segments.length - 1] ?? '',
94    });
95  }
96  return found;
97}
98
99/** `Name <email> (url)` or `{ name, email }` from package.json's `author`. */
100export function packageAuthor(text: string): { name?: string; email?: string } {
101  let data: unknown;
102  try {
103    data = JSON.parse(text);
104  } catch {
105    return {};
106  }
107  if (typeof data !== 'object' || data === null || !('author' in data)) return {};
108  const author = data.author;
109  if (typeof author === 'string') {
110    const match = /^([^<(]*?)\s*(?:<([^>]+)>)?\s*(?:\([^)]*\))?$/.exec(author.trim());
111    return {
112      ...(match?.[1] ? { name: match[1] } : {}),
113      ...(match?.[2] ? { email: match[2] } : {}),
114    };
115  }
116  if (typeof author === 'object' && author !== null) {
117    const name = 'name' in author && typeof author.name === 'string' ? author.name : undefined;
118    const email = 'email' in author && typeof author.email === 'string' ? author.email : undefined;
119    return { ...(name ? { name } : {}), ...(email ? { email } : {}) };
120  }
121  return {};
122}
123
124function lastSegment(path: string): string {
125  return (
126    path
127      .replace(/[\\/]+$/, '')
128      .split(/[\\/]/)
129      .pop() ?? ''
130  );
131}
132
133/** Every candidate, first source wins for a repeated value; nothing shorter than a rule allows. */
134export function collectCandidates(inputs: ScanInputs): ScanCandidate[] {
135  const candidates: ScanCandidate[] = [];
136  const seen = new Set<string>();
137  const add = (value: string | undefined, label: string, source: string, description?: string) => {
138    const trimmed = value?.trim() ?? '';
139    if (trimmed.length < MIN_CUSTOM_VALUE_LENGTH || TRIVIAL_VALUE.test(trimmed)) return;
140    const key = ruleKey(trimmed);
141    if (seen.has(key)) return;
142    seen.add(key);
143    candidates.push({ value: trimmed, label, source, ...(description ? { description } : {}) });
144  };
145
146  add(inputs.gitName, 'NAME', 'git config user.name', SCAN_DESCRIPTIONS.gitName);
147  add(inputs.gitEmail, 'EMAIL', 'git config user.email', SCAN_DESCRIPTIONS.gitEmail);
148
149  for (const remote of parseRemotes(inputs.remotes ?? '')) {
150    const isPublic = PUBLIC_GIT_HOSTS.includes(remote.host);
151    if (!isPublic) add(remote.host, 'HOST', 'git remote host', SCAN_DESCRIPTIONS.remoteHost);
152    add(remote.owner, 'ORG', 'git remote owner', SCAN_DESCRIPTIONS.remoteOwner);
153    if (!isPublic) add(remote.repo, 'PROJECT', 'git remote repository', SCAN_DESCRIPTIONS.remoteRepo);
154  }
155
156  for (const line of (inputs.logAuthors ?? '').split(/\r?\n/)) {
157    const [name, email] = line.split('\u0000');
158    if (!name || BOT_AUTHOR.test(name) || BOT_AUTHOR.test(email ?? '')) continue;
159    add(name, 'NAME', 'git log author', SCAN_DESCRIPTIONS.logName);
160    add(email, 'EMAIL', 'git log author', SCAN_DESCRIPTIONS.logEmail);
161  }
162
163  const author = inputs.packageJson ? packageAuthor(inputs.packageJson) : {};
164  add(author.name, 'NAME', 'package.json author', SCAN_DESCRIPTIONS.packageName);
165  add(author.email, 'EMAIL', 'package.json author', SCAN_DESCRIPTIONS.packageEmail);
166
167  const host = inputs.hostname?.trim().replace(LOCAL_HOST_SUFFIX, '');
168  add(host, 'HOST', 'computer name', SCAN_DESCRIPTIONS.computer);
169  const account = inputs.home ? lastSegment(inputs.home) : '';
170  if (!GENERIC_ACCOUNTS.includes(account.toLowerCase()))
171    add(account, 'USER', 'home folder name', SCAN_DESCRIPTIONS.account);
172
173  for (const file of inputs.envFiles ?? []) {
174    for (const { key, value } of parseEnvFile(file.text)) {
175      if (value.startsWith('${') || value.includes('{{')) continue;
176      add(value, SECRET_KEY.test(key) ? 'SECRET' : 'ENV', `${file.name} (${key})`, SCAN_DESCRIPTIONS.envValue(key));
177    }
178  }
179  return candidates;
180}
181
182/**
183 * Candidates in any text files (a path scan): `KEY=value` values and host names
184 * on private networks. Values masked anyway are counted, not offered, by `triage`.
185 */
186export function fileCandidates(
187  files: ReadonlyArray<{ readonly name: string; readonly text: string }>,
188): ScanCandidate[] {
189  const candidates: ScanCandidate[] = [];
190  const seen = new Set<string>();
191  const add = (value: string, label: string, source: string, description?: string) => {
192    const key = ruleKey(value);
193    if (value.length < MIN_CUSTOM_VALUE_LENGTH || seen.has(key)) return;
194    seen.add(key);
195    candidates.push({ value, label, source, ...(description ? { description } : {}) });
196  };
197  for (const file of files) {
198    for (const { key, value } of envAssignments(file.text)) {
199      add(value, SECRET_KEY.test(key) ? 'SECRET' : 'ENV', `${file.name} (${key})`, SCAN_DESCRIPTIONS.envValue(key));
200    }
201    for (const { start, end } of findInternalHosts(file.text)) add(file.text.slice(start, end), 'HOST', file.name);
202  }
203  return candidates;
204}
205
206/** The KEY=value values in these files: masked wherever they appear as an assignment while the env detector is on. */
207export function assignedValues(files: ReadonlyArray<{ readonly text: string }>): Set<string> {
208  return new Set(files.flatMap(file => envAssignments(file.text).map(({ value }) => value)));
209}
210
211/** Distinct values the current rules and detectors already mask in these files, by label. */
212export function maskedKinds(
213  files: ReadonlyArray<{ readonly text: string }>,
214  matching: MatchingOptions,
215): Array<readonly [string, number]> {
216  const byLabel = new Map<string, Set<string>>();
217  for (const file of files) {
218    for (const match of findPrivateMatches(file.text, matching)) {
219      const values = byLabel.get(match.category) ?? new Set<string>();
220      values.add(match.value);
221      byLabel.set(match.category, values);
222    }
223  }
224  return [...byLabel.entries()]
225    .map(([label, values]) => [label, values.size] as const)
226    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
227}
228
229/** Candidates by label, for command output that must not carry values: `2 NAME, 1 HOST`. */
230export function countByLabel(candidates: readonly ScanCandidate[]): string {
231  const counts = new Map<string, number>();
232  for (const candidate of candidates) counts.set(candidate.label, (counts.get(candidate.label) ?? 0) + 1);
233  return [...counts.entries()].map(([label, count]) => `${count} ${label}`).join(', ');
234}
235
236/** Whether the current rules, the email scanner or a detector already mask the whole value. */
237export function isMasked(value: string, matching: MatchingOptions): boolean {
238  return findPrivateMatches(value, matching).some(match => match.start === 0 && match.end === value.length);
239}
240
241/** Splits found values into new ones, ones already masked, and ones dismissed before. */
242export async function triage(
243  found: readonly ScanCandidate[],
244  matching: MatchingOptions,
245  ignored: ReadonlySet<string>,
246  /** Also masked in context (a KEY=value value the env detector hides), though not on its own. */
247  maskedInContext: (candidate: ScanCandidate) => boolean = () => false,
248): Promise<{ candidates: ScanCandidate[]; covered: number; ignored: number }> {
249  const candidates: ScanCandidate[] = [];
250  let covered = 0;
251  let dismissed = 0;
252  for (const candidate of found) {
253    if (maskedInContext(candidate) || isMasked(candidate.value, matching)) covered += 1;
254    else if (ignored.has(await fingerprint(candidate.value))) dismissed += 1;
255    else candidates.push(candidate);
256  }
257  return { candidates, covered, ignored: dismissed };
258}
259
hooks/session-map.ts 59 lines
1/**
2 * The placeholder map of one session, kept in a local file so `/resume` can
3 * restore what earlier turns masked. Pure: register.ts reads and writes the
4 * file. The file holds original values: it never leaves this machine.
5 */
6import { SESSION_MAP_FORMAT, SESSION_MAP_VERSION } from './defaults.ts';
7import type { Notes, Vault } from './privacy.ts';
8
9export interface SessionMap {
10  readonly vault: Vault;
11  readonly notes: Notes;
12  /** Placeholders whose description the model has already been told. */
13  readonly sent: Readonly<Record<string, true>>;
14}
15
16/** Session ids become file names: letters, digits, `-` and `_` only. */
17export function isSessionId(id: string): boolean {
18  return /^[A-Za-z0-9_-]{1,128}$/.test(id);
19}
20
21export function serializeSessionMap(map: SessionMap, now: Date): string {
22  return JSON.stringify({
23    format: SESSION_MAP_FORMAT,
24    version: SESSION_MAP_VERSION,
25    updatedAt: now.toISOString(),
26    ...map,
27  });
28}
29
30function stringRecord(value: unknown): Record<string, string> | undefined {
31  if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
32  const entries = Object.entries(value);
33  return entries.every(([, item]) => typeof item === 'string')
34    ? (Object.fromEntries(entries) as Record<string, string>)
35    : undefined;
36}
37
38/** The saved map, or undefined for a file that is not one (a person may edit it). */
39export function parseSessionMap(text: string): SessionMap | undefined {
40  let data: unknown;
41  try {
42    data = JSON.parse(text);
43  } catch {
44    return undefined;
45  }
46  if (typeof data !== 'object' || data === null) return undefined;
47  const record = data as Record<string, unknown>;
48  if (record.format !== SESSION_MAP_FORMAT || typeof record.version !== 'number') return undefined;
49  if (record.version > SESSION_MAP_VERSION) return undefined;
50  const vault = stringRecord(record.vault);
51  if (!vault) return undefined;
52  const notes = stringRecord(record.notes) ?? {};
53  const sent: Record<string, true> = {};
54  if (typeof record.sent === 'object' && record.sent !== null) {
55    for (const [token, flag] of Object.entries(record.sent)) if (flag === true) sent[token] = true;
56  }
57  return { vault, notes, sent };
58}
59
hooks/status.ts 150 lines
1/**
2 * What the mod reports about itself: counts and settings only, never a value
3 * or a placeholder. Pure; register.ts decides where it is shown or written.
4 */
5import type { Vault } from './privacy.ts';
6import type { RestoreMode } from './policy.ts';
7import { DEFAULT_SCHEME, EMAIL_LABEL, type PlaceholderScheme } from './placeholder.ts';
8import { emptyMetrics, healthState, type HealthState, type Metrics } from './metrics.ts';
9import { DETECTOR_LABELS } from './detectors.ts';
10
11export type StatusLine = 'off' | 'count' | 'detailed';
12
13export const STATUS_LINES: readonly StatusLine[] = ['off', 'count', 'detailed'];
14
15export interface StatusStats {
16  readonly masked: number;
17  readonly emails: number;
18  /** Values found by other built-in detectors (keys, cards, IDs, ...). */
19  readonly detected: number;
20  readonly custom: number;
21  readonly mode: RestoreMode;
22  readonly audit: boolean;
23  readonly streaming: boolean;
24  readonly occurrences: number;
25  readonly lastTurn: number;
26  readonly restored: number;
27  readonly kept: number;
28  readonly denied: number;
29  readonly audits: number;
30  readonly blocked: number;
31  readonly media: number;
32  readonly state: HealthState;
33  readonly version: string;
34}
35
36/** Built-in templates; `status_format` replaces them when set. */
37export const STATUS_TEMPLATES: Readonly<Record<Exclude<StatusLine, 'off'>, string>> = {
38  count: '{masked} private values masked',
39  detailed: '{masked} masked ({emails} email, {detected} detected, {custom} custom) · tools: {mode} · audit: {audit}',
40};
41
42/** Names a template may use: `{masked}`, `{blocked}`, ... */
43export const STATUS_FIELDS: readonly (keyof StatusStats)[] = [
44  'masked',
45  'emails',
46  'detected',
47  'custom',
48  'mode',
49  'audit',
50  'streaming',
51  'occurrences',
52  'lastTurn',
53  'restored',
54  'kept',
55  'denied',
56  'audits',
57  'blocked',
58  'media',
59  'state',
60  'version',
61];
62
63export interface StatusSettings {
64  readonly mode: RestoreMode;
65  readonly audit: boolean;
66  readonly streaming: boolean;
67  /** Rejected format plus ignored custom entries. */
68  readonly configProblems: number;
69}
70
71export function statusStats(
72  vault: Vault,
73  settings: StatusSettings,
74  metrics: Metrics = emptyMetrics(0),
75  scheme: PlaceholderScheme = DEFAULT_SCHEME,
76): StatusStats {
77  const labels = Object.keys(vault).map(token => scheme.labelOf(token));
78  const emails = labels.filter(label => label === EMAIL_LABEL).length;
79  const detected = labels.filter(
80    label => label !== undefined && label !== EMAIL_LABEL && DETECTOR_LABELS.has(label),
81  ).length;
82  return {
83    masked: labels.length,
84    emails,
85    detected,
86    custom: labels.length - emails - detected,
87    mode: settings.mode,
88    audit: settings.audit,
89    streaming: settings.streaming,
90    occurrences: metrics.occurrences,
91    lastTurn: metrics.lastTurnMasked,
92    restored: metrics.restoredCalls,
93    kept: metrics.keptCalls,
94    denied: metrics.deniedCalls,
95    audits: metrics.audits,
96    blocked: metrics.blocked,
97    media: metrics.mediaWithheld,
98    state: healthState(metrics, settings.configProblems),
99    version: metrics.version,
100  };
101}
102
103function show(value: StatusStats[keyof StatusStats]): string {
104  return typeof value === 'boolean' ? (value ? 'on' : 'off') : String(value);
105}
106
107/**
108 * The status text, or undefined to clear it: when the line is off, or while
109 * nothing is masked. Unknown `{names}` stay as written.
110 */
111export function formatStatus(line: StatusLine, format: string, stats: StatusStats): string | undefined {
112  if (line === 'off' || stats.masked === 0) return undefined;
113  const template = format.trim() || STATUS_TEMPLATES[line];
114  return template.replace(/\{(\w+)\}/g, (whole, name: string) =>
115    (STATUS_FIELDS as readonly string[]).includes(name) ? show(stats[name as keyof StatusStats]) : whole,
116  );
117}
118
119/** Settings echoed in the status file; never values, labels or paths. */
120export interface StatusFileConfig {
121  readonly placeholderFormat: string;
122  readonly idLength: number;
123  readonly statusLine: StatusLine;
124  readonly extraKeepTools: number;
125  readonly ignoredRules: number;
126  readonly formatProblem: boolean;
127}
128
129/** The JSON a status-line script reads (`status_file`). */
130export function statusFileContent(
131  stats: StatusStats,
132  metrics: Metrics,
133  config: StatusFileConfig,
134  extra: { model?: string; updatedAt: number },
135): string {
136  const { bySource, restoredByTool, lastBlock, hookErrors, startedAt, lastEvent, surface } = metrics;
137  return `${JSON.stringify({
138    ...stats,
139    ...extra,
140    startedAt,
141    lastEvent,
142    surface,
143    bySource,
144    restoredByTool,
145    lastBlock,
146    hookErrors,
147    config,
148  })}\n`;
149}
150
hooks/metrics.ts 115 lines
1/**
2 * Session counters for the status line and status file. Pure: every change
3 * returns a new object, so register.ts can apply it with `update` on $.state.
4 * Counts, kinds and positions only; never a value, placeholder or path.
5 * The shapes are the state contract's, in types/index.d.ts.
6 */
7import type { RedactRestoreBlockInfo, RedactRestoreMetrics } from '../types/index.d.ts';
8
9export type MaskSource = 'prompt' | 'context' | 'attachment' | 'systemPrompt' | 'conversation';
10
11export type BlockInfo = RedactRestoreBlockInfo;
12export type Metrics = RedactRestoreMetrics;
13
14export function emptyMetrics(now: number, version = '', surface = ''): Metrics {
15  return {
16    startedAt: now,
17    updatedAt: now,
18    lastEvent: 'session.start',
19    version,
20    surface,
21    occurrences: 0,
22    bySource: {},
23    lastTurnMasked: 0,
24    restoredCalls: 0,
25    restoredByTool: {},
26    keptCalls: 0,
27    deniedCalls: 0,
28    audits: 0,
29    blocked: 0,
30    lastAuditBlocked: false,
31    lastBlock: null,
32    mediaWithheld: 0,
33    promptsDropped: 0,
34    hookErrors: {},
35  };
36}
37
38function bump(counts: Readonly<Record<string, number>>, key: string, by = 1): Record<string, number> {
39  return { ...counts, [key]: (counts[key] ?? 0) + by };
40}
41
42function touched(m: Metrics, event: string, now: number): Metrics {
43  return { ...m, lastEvent: event, updatedAt: now };
44}
45
46/** A change to apply: `(metrics, now) => metrics`. */
47export type MetricsChange = (m: Metrics, now: number) => Metrics;
48
49export const metricsChange = {
50  newTurn: (): MetricsChange => (m, now) => touched({ ...m, lastTurnMasked: 0 }, 'prompt.submit', now),
51  masked:
52    (source: MaskSource, count: number, event: string): MetricsChange =>
53    (m, now) =>
54      touched(
55        {
56          ...m,
57          occurrences: m.occurrences + count,
58          lastTurnMasked: m.lastTurnMasked + count,
59          bySource: bump(m.bySource, source, count),
60        },
61        event,
62        now,
63      ),
64  media:
65    (count: number): MetricsChange =>
66    (m, now) =>
67      touched({ ...m, mediaWithheld: m.mediaWithheld + count }, 'session.append', now),
68  promptDropped: (): MetricsChange => (m, now) =>
69    touched({ ...m, promptsDropped: m.promptsDropped + 1 }, 'prompt.submit', now),
70  tool:
71    (tool: string, action: 'restore' | 'keep' | 'deny'): MetricsChange =>
72    (m, now) =>
73      touched(
74        action === 'restore'
75          ? { ...m, restoredCalls: m.restoredCalls + 1, restoredByTool: bump(m.restoredByTool, tool) }
76          : action === 'keep'
77            ? { ...m, keptCalls: m.keptCalls + 1 }
78            : { ...m, deniedCalls: m.deniedCalls + 1 },
79        'tool.call',
80        now,
81      ),
82  audit:
83    (block: BlockInfo | null): MetricsChange =>
84    (m, now) =>
85      touched(
86        {
87          ...m,
88          audits: m.audits + 1,
89          lastAuditBlocked: block !== null,
90          blocked: m.blocked + (block ? 1 : 0),
91          lastBlock: block ?? m.lastBlock,
92        },
93        'turn.step',
94        now,
95      ),
96  hookError:
97    (hook: string): MetricsChange =>
98    (m, now) =>
99      touched({ ...m, hookErrors: bump(m.hookErrors, hook) }, hook, now),
100};
101
102/** Applies several changes in order. */
103export function combine(...changes: MetricsChange[]): MetricsChange {
104  return (m, now) => changes.reduce((current, change) => change(current, now), m);
105}
106
107export type HealthState = 'ok' | 'warn' | 'blocked';
108
109/** `blocked` while the last audited request was refused; `warn` on hook errors or rejected settings. */
110export function healthState(m: Metrics, configProblems: number): HealthState {
111  if (m.lastAuditBlocked) return 'blocked';
112  const errors = Object.values(m.hookErrors).reduce((sum, n) => sum + n, 0);
113  return errors > 0 || configProblems > 0 ? 'warn' : 'ok';
114}
115