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

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.
/plugin install redact-restore --marketplace isandrel/redact-restore
🧪 Try it in 2 minutes with a fake email
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:
{{PRIVATE:EMAIL#3fa1c09e8b2d}}, never the value.Bash, Edit, Write, Grep and local MCP tools receive the real values, so builds, scripts and file edits are correct.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.
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:
| Where | You should see | Which proves |
|---|---|---|
| The reply on your screen | alice@example.com | Placeholders 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 terminal | alice@example.com | Local tools receive the original |
/redact-restore session | One masked value of kind EMAIL, with its placeholder | What 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.
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
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"]
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"]
| Feature | What it does |
|---|---|
| 🎯 Your own values | List 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+ detectors | 200+ 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 depth | Masks prompts, context, attachments, the system prompt and every stored row; audits each request before it is sent; withholds images it cannot scan. |
| 🔁 Reversible by design | Same 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 revealed | The 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. |
| ⚙️ Configurable | Placeholder 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. |
| 📊 Visible | A 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). |
| 🧪 Tested | Unit 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. |
| Capability | API gateway or proxy | One-way redaction hooks | Redact 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 | ❌ | ✅ |
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./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 locallimits it to one folder.
| Document | What it covers |
|---|---|
| Configuration | Every option, placeholder formats, labels and aliases, status fields |
| Detectors | What is detected, how it is validated, how to turn detectors on or off |
| Security model | Coverage of every path to the model, review findings, known limitations |
| Compatibility | CLI and Desktop Code test matrix |
| Test plan | Automated and live test steps |
| Changelog | Release notes |
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.
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.
Thanks to LINUX DO for supporting open-source developers and community sharing.
GNU Affero General Public License v3.0. The vendored gitleaks rules in hooks/vendor/ are MIT-licensed; see hooks/vendor/LICENSE-gitleaks.txt.
hooks/register.ts 1468 lines1import { 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 lines1import { 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}
188hooks/defaults.ts 274 lines1/**
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;
274hooks/matcher.ts 514 lines1/** 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}
514hooks/pane.tsx 723 lines1/**
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}
723hooks/placeholder.ts 187 lines1/**
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}
187hooks/rules.ts 470 lines1/**
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}
470hooks/patterns.ts 312 lines1/**
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}
312hooks/scan.ts 259 lines1/**
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}
259hooks/session-map.ts 59 lines1/**
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}
59hooks/status.ts 150 lines1/**
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}
150hooks/metrics.ts 115 lines1/**
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