SLOPSHOPPER

redact

Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it. Reach L0: no network, no processes, no…

newguardcommandprompt
★ 1v0.1.0MITupdated 2026-10-03karanb192/claude-code-redact/plugins/redact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact
› fix the failing auth test and add an audit log call ● redact: scanning failed, so this row was withheld from the model (a tool result) ● redact: scanning failed, so this row was withheld from the model (a tool result) ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /redact ⎿ redact: nothing hidden yet this session. Scanning secrets only (set pii to true for email, phone, cards, national ids) ● redact: scanning failed, so this row was withheld from the model (a tool result) ● redact: scanning failed, so this row was withheld from the model (a tool result) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

redact

Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it, and puts the real value back only inside Edit, Write and NotebookEdit arguments so file edits still work.

Built on Claude Code 2.1.288. Proven on 2.1.288 (stages: validate, load, typecheck, test, command, isolation, plus a live interactive session with a real file write). Requires Claude Code 2.1.287 or later.

What it can reach

❯ ./register.ts hooks: session.start, prompt.submit, session.append, tool.call{tool=Edit|Write|NotebookEdit}, prompt.section{name=env_info_simple}, command.run{command=redact} ❯ ./register.ts calls: $.command.register, $.state.get, $.state.set, $.ui.log ❯ ./register.ts state writes: redact.counts, redact.restored, redact.salt, redact.vault ❯ ./register.ts state reads: redact.counts, redact.restored, redact.salt, redact.vault

Reach L0, draws and remembers.

Threat model for redact (reach L0, draws and remembers)
1. Reads:    the text of every prompt and every stored row (prompts, tool results, Claude's blocks, attachments, notices) and the arguments of Edit, Write and NotebookEdit calls, in memory; state keys redact.vault, redact.salt, redact.counts, redact.restored; no files, no env vars, no settings
2. Runs:     nothing
3. Sends:    nothing leaves the machine; no network call, no model call
4. Persists: placeholder-to-value pairs, the salt and counters in $.state for this session only (reset by /clear, /resume, /branch; gone at exit); nothing in $.store, no files
5. Hostile input: a crafted row can only change what gets redacted; the rule set is fixed in source and nothing reaches a process, a file, the network or the model. A crafted placeholder in a tool argument restores only a value this session hid itself; an unknown one passes through unchanged. A row that makes the scanner throw is withheld from the model, never stored raw

How it works

  1. prompt.submit: the typed prompt is scanned and each hit becomes [REDACTED:LABEL#hash] before the engine queues it.
  2. session.append: every row the conversation stores is scanned the same way: tool results (file reads, shell output, MCP results), Claude's own blocks, attachments such as nested memory, notices. The rewritten row is what the model reads and what the session file keeps.
  3. tool.call on Edit, Write and NotebookEdit: placeholders in the arguments are swapped back for the real value, so an edit to a line that held a key still matches the file. Bash never gets the real value.
  4. prompt.section: one paragraph is appended to the environment section telling Claude the placeholders are opaque.
  5. /redact: the session total by label and how many placeholders were restored.

The hash is FNV-1a over a per-session salt plus the value, so the same secret gets the same placeholder across rows, edits and compaction summaries, and a placeholder from another session means nothing here.

Detection is deterministic: 24 secret rules and 7 PII rules, regex plus checksums (Luhn, Verhoeff, IBAN mod-97, JWT header decode), no model call. The list with sources is in RULES.md.

False positives on ordinary code

A redactor that fires on normal work gets uninstalled in a week, so the number to defend is the false-positive count, not the catch count. tools/fp-corpus.mjs shallow-fetches 11 public repositories at pinned commits (lockfiles, Go sums, Terraform, Markdown, minified JS, JWT and PEM test vectors, a dotenv test suite) and scans every text file with the secret rules:

Files scannedSecret-rule hitsReal test keys and vectorsFake fixturesFalse positives
42,836686080

Every hit was opened at its line and labelled by hand; the labels live in tools/fp-corpus-labels.json, so a rerun that produces a new hit shows it as unreviewed instead of counting it. Rerun it yourself:

node tools/fp-corpus.mjs

The full per-hit list is in tools/fp-corpus-results.md at the repo root. The 60 real-shaped hits are complete private keys and signed JWTs published as test vectors, and demo database URLs with a password; a redactor should hide those. The PII rules, off by default, hit 650 times on the same corpus (mostly email addresses in docs), which is why they are opt-in.

Install

One session: claude --plugin-dir ./plugins/redact. To keep it:

claude plugin marketplace add karanb192/claude-code-redact
claude plugin install redact@claude-code-redact

then /reload-plugins in an open session. Installed copies are cached by version: bump version before reinstalling.

Options

Stored under pluginConfigs in settings; a change reloads the mod.

  • pii (boolean, default false): also hide email, phone, payment cards, US SSN, Aadhaar, PAN and IBAN. Secrets are always hidden.
  • quiet (boolean, default false): no redact: hid ... line in the transcript. /redact still reports totals.
  • off (string, default empty): comma-separated rule ids to skip, for example generic-secret,email. Ids are in RULES.md.

Limitations

  • Three host bookkeeping records are stored as made and can hold a raw value. The engine lets a mod rewrite the conversation rows the model reads, not the records around them. (a) The toolUseResult record of a Write, Edit or NotebookEdit call keeps the file content as written, so a value restored into a file lands there in clear. (b) A slash command's args stamp keeps the typed arguments. (c) In headless claude -p only, the queue-operation record keeps the typed prompt, because it is written before prompt.submit runs; an interactive session writes no such record. None of the three is sent to the model.
  • tool_use blocks are not rewritable. The engine puts the model's own tool call blocks back as made; a mod may rewrite text blocks and tool results only. The model only ever sees placeholders, so a raw value in a tool call means it came from text the rules missed.
  • A secret the rules do not know stays visible. Generic key=value detection needs the value to look like a secret (length and entropy): password=hunter2 is caught, token=abc is not.
  • A secret split across two rows, or shown partially (the last four characters), is not caught.
  • Images are not scanned. A screenshot of a key goes through.
  • Claude cannot search for a secret by value. Grep for a placeholder finds nothing. Search by the line around it.
  • Plugins loaded ahead of this one see the row before it is rewritten.
  • The screen may show a row just before its rewrite. The model and the session file never see that form.
  • Nothing is drawn in claude -p, the SDK, the VS Code panel or cloud sessions. The redact: hid ... line arrives as ui_log there. /redact works everywhere.
  • A pattern guard is not a permission rule. A determined prompt can describe a secret in words the rules do not match.

Uninstall

Disable it in /plugin. It leaves nothing: no store keys, no files.

Source 4 files
hooks/register.ts 129 lines
1import type { EngineInterface, PluginOptions, Register, SessionAppendInput } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import { describe, fnv1a, redactBlocks, redactText, restore, tally, withhold, type Settings } from './scrub'
4
5const vault = atom({ plugin: 'redact', key: 'vault' } as const, {} as Record<string, string>)
6const salt = atom({ plugin: 'redact', key: 'salt' } as const, '')
7const counts = atom({ plugin: 'redact', key: 'counts' } as const, {} as Record<string, number>)
8const restored = atom({ plugin: 'redact', key: 'restored' } as const, 0)
9
10const NOTE = [
11  'Redaction notice: strings shaped like [REDACTED:LABEL#hex] are placeholders a local redactor inserted',
12  'in place of secrets and personal data before you saw them. The real value is restored only when a',
13  'placeholder is passed verbatim in Edit, Write or NotebookEdit arguments. Never ask the person for the',
14  'hidden value, never try to reconstruct it, and never rely on a placeholder in a shell command.',
15].join(' ')
16
17const DOORS: Record<string, string> = {
18  'prompt': 'your prompt',
19  'command': 'a command result',
20  'response': "Claude's reply",
21  'tool-result': 'a tool result',
22  'tool-message': 'a tool message',
23  'delivery': 'a delivered message',
24  'attachment': 'an attachment',
25  'hook-context': 'hook context',
26  'note': 'a plugin note',
27  'compaction': 'the compaction summary',
28  'notice': 'a notice',
29}
30
31function settings(options: PluginOptions): Settings {
32  const off = typeof options.off === 'string' ? options.off.split(',').map(s => s.trim()).filter(Boolean) : []
33  return { pii: options.pii === true, quiet: options.quiet === true, off: new Set(off) }
34}
35
36async function ensureSalt($: EngineInterface): Promise<string> {
37  const have = await read($, salt)
38  if (have) return have
39  const fresh = fnv1a(String(Math.random()) + String(Date.now())) + fnv1a(String(Math.random()) + String(performance.now()))
40  await update($, salt, () => fresh)
41  return fresh
42}
43
44async function remember($: EngineInterface, pairs: ReadonlyArray<readonly [string, string]>, found: Record<string, number>, cfg: Settings, place: string): Promise<void> {
45  await update($, vault, v => ({ ...v, ...Object.fromEntries(pairs) }))
46  await update($, counts, c => {
47    const total: Record<string, number> = { ...c }
48    for (const [label, n] of Object.entries(found)) total[label] = (total[label] ?? 0) + n
49    return total
50  })
51  if (!cfg.quiet) $.ui.log(`hid ${describe(found)} in ${place}`)
52}
53
54function where(e: SessionAppendInput): string {
55  const agent = e.agentId ? ' (subagent)' : ''
56  return (DOORS[e.door] ?? e.door) + agent
57}
58
59export const register: Register = (on, options) => {
60  const cfg = settings(options)
61
62  on('session.start', async ($, e, next) => {
63    const started = await next(e)
64    await $.command.register({ name: 'redact', description: 'Show what redact hid in this session' })
65    return started
66  })
67
68  // The typed prompt is scrubbed before the turn starts, so the on-screen echo
69  // and the command args hold the placeholder. The engine's queue record is
70  // written earlier in the headless path and is out of reach (see README).
71  on('prompt.submit', async ($, e, next) => {
72    const s = await ensureSalt($)
73    const r = redactText(e.text, s, cfg)
74    if (r.hits.length === 0) return next(e)
75    await remember($, r.pairs, tally(r.hits), cfg, 'your prompt')
76    return next({ ...e, text: r.text })
77  })
78
79  on('session.append', async ($, e, next) => {
80    const s = await ensureSalt($)
81    const r = redactBlocks(e.message.content, s, cfg)
82    if (r.hits.length === 0) return next(e)
83    await remember($, r.pairs, tally(r.hits), cfg, where(e))
84    return next({ ...e, message: { ...e.message, content: r.blocks } })
85  }).catch(($, e, next) => {
86    if (next.called) return next(e)
87    $.ui.log(`scanning failed, so this row was withheld from the model (${where(e)})`)
88    return next({ ...e, message: { ...e.message, content: withhold(e.message.content) } })
89  })
90
91  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
92    const v = await read($, vault)
93    if (e.tool === 'Edit') {
94      const o = restore(e.old_string, v)
95      const n = restore(e.new_string, v)
96      if (o.n + n.n === 0) return next(e)
97      await update($, restored, k => k + o.n + n.n)
98      return next({ ...e, old_string: o.text, new_string: n.text })
99    }
100    if (e.tool === 'Write') {
101      const c = restore(e.content, v)
102      if (c.n === 0) return next(e)
103      await update($, restored, k => k + c.n)
104      return next({ ...e, content: c.text })
105    }
106    if (e.tool === 'NotebookEdit') {
107      const c = restore(e.new_source, v)
108      if (c.n === 0) return next(e)
109      await update($, restored, k => k + c.n)
110      return next({ ...e, new_source: c.text })
111    }
112    return next(e)
113  })
114
115  on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
116    const r = await next(e)
117    return { text: r.text ? `${r.text}\n\n${NOTE}` : NOTE }
118  })
119
120  on('command.run', { command: 'redact' }, async $ => {
121    const c = await read($, counts)
122    const k = await read($, restored)
123    const total = Object.values(c).reduce((a, b) => a + b, 0)
124    const rules = cfg.pii ? 'secrets and PII' : 'secrets only (set pii to true for email, phone, cards, national ids)'
125    if (total === 0) return { text: `nothing hidden yet this session. Scanning ${rules}.` }
126    return { text: `hid ${total} values this session (${describe(c)}); restored ${k} placeholders inside Edit, Write and NotebookEdit arguments. Scanning ${rules}.` }
127  })
128}
129
hooks/scrub.ts 99 lines
1import type { ApiContentBlock } from 'claude-code'
2import { scan, type Hit } from './rules'
3
4export type Settings = { pii: boolean; quiet: boolean; off: ReadonlySet<string> }
5
6export const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]+#[0-9a-f]{8}\]/g
7
8export function fnv1a(s: string): string {
9  let h = 0x811c9dc5
10  for (let i = 0; i < s.length; i++) {
11    h ^= s.charCodeAt(i)
12    h = Math.imul(h, 0x01000193) >>> 0
13  }
14  return h.toString(16).padStart(8, '0')
15}
16
17export function placeholder(hit: Hit, salt: string): string {
18  return `[REDACTED:${hit.label}#${fnv1a(salt + hit.value)}]`
19}
20
21export type Redacted = { text: string; hits: Hit[]; pairs: Array<[string, string]> }
22
23export function redactText(text: string, salt: string, cfg: Settings): Redacted {
24  const hits = scan(text, { pii: cfg.pii, off: cfg.off })
25  if (hits.length === 0) return { text, hits, pairs: [] }
26  const pairs: Array<[string, string]> = []
27  let out = ''
28  let last = 0
29  for (const h of hits) {
30    const p = placeholder(h, salt)
31    out += text.slice(last, h.start) + p
32    last = h.end
33    pairs.push([p, h.value])
34  }
35  out += text.slice(last)
36  return { text: out, hits, pairs }
37}
38
39export type RedactedBlocks = { blocks: ApiContentBlock[]; hits: Hit[]; pairs: Array<[string, string]> }
40
41export function redactBlocks(blocks: readonly ApiContentBlock[], salt: string, cfg: Settings): RedactedBlocks {
42  const out: ApiContentBlock[] = []
43  const hits: Hit[] = []
44  const pairs: Array<[string, string]> = []
45  for (const block of blocks) {
46    if (block.type === 'text' && typeof block.text === 'string') {
47      const r = redactText(block.text, salt, cfg)
48      hits.push(...r.hits)
49      pairs.push(...r.pairs)
50      out.push(r.hits.length ? { ...block, text: r.text } : block)
51    } else if (block.type === 'tool_result' && typeof block.content === 'string') {
52      const r = redactText(block.content, salt, cfg)
53      hits.push(...r.hits)
54      pairs.push(...r.pairs)
55      out.push(r.hits.length ? { ...block, content: r.text } : block)
56    } else if (block.type === 'tool_result' && Array.isArray(block.content)) {
57      const r = redactBlocks(block.content as ApiContentBlock[], salt, cfg)
58      hits.push(...r.hits)
59      pairs.push(...r.pairs)
60      out.push(r.hits.length ? { ...block, content: r.blocks } : block)
61    } else {
62      out.push(block)
63    }
64  }
65  return { blocks: out, hits, pairs }
66}
67
68export const WITHHELD = '[redact withheld this content: the redactor failed while scanning it]'
69
70// Fail closed: a row the redactor could not scan is replaced, never stored as it came.
71export function withhold(blocks: readonly ApiContentBlock[]): ApiContentBlock[] {
72  return blocks.map(block => {
73    if (block.type === 'text') return { ...block, text: WITHHELD }
74    if (block.type === 'tool_result') return { ...block, content: WITHHELD }
75    return block
76  })
77}
78
79export function tally(hits: readonly Hit[]): Record<string, number> {
80  const t: Record<string, number> = {}
81  for (const h of hits) t[h.label] = (t[h.label] ?? 0) + 1
82  return t
83}
84
85export function describe(t: Record<string, number>): string {
86  return Object.entries(t).sort((a, b) => b[1] - a[1]).map(([label, n]) => `${n} ${label}`).join(', ')
87}
88
89export function restore(text: string, vault: Record<string, string>): { text: string; n: number } {
90  let n = 0
91  const out = text.replace(PLACEHOLDER, p => {
92    const real = vault[p]
93    if (real === undefined) return p
94    n += 1
95    return real
96  })
97  return { text: out, n }
98}
99
hooks/rules.ts 682 lines
1export type RuleKind = 'secret' | 'pii'
2
3export type Rule = {
4  id: string
5  label: string
6  kind: RuleKind
7  pattern: RegExp
8  group?: number
9  verify?: (value: string) => boolean
10  // sees the text and the match start, for context a regex without lookbehind cannot check
11  before?: (text: string, at: number) => boolean
12  source: string
13}
14
15export type Hit = { id: string; label: string; kind: RuleKind; start: number; end: number; value: string }
16
17const GITLEAKS = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml'
18const GITHUB_PATTERNS = 'https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns'
19
20function entropy(s: string): number {
21  if (s.length === 0) return 0
22  const counts = new Map<string, number>()
23  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
24  let bits = 0
25  for (const n of counts.values()) {
26    const p = n / s.length
27    bits -= p * Math.log2(p)
28  }
29  return bits
30}
31
32function luhn(digits: string): boolean {
33  let sum = 0
34  let double = false
35  for (let i = digits.length - 1; i >= 0; i--) {
36    let d = digits.charCodeAt(i) - 48
37    if (double) {
38      d *= 2
39      if (d > 9) d -= 9
40    }
41    sum += d
42    double = !double
43  }
44  return digits.length > 0 && sum % 10 === 0
45}
46
47const VERHOEFF_D = [
48  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
49  [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
50  [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
51  [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
52  [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
53  [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
54  [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
55  [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
56  [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
57  [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
58]
59const VERHOEFF_P = [
60  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
61  [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
62  [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
63  [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
64  [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
65  [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
66  [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
67  [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
68]
69
70function verhoeff(digits: string): boolean {
71  let c = 0
72  for (let i = 0; i < digits.length; i++) {
73    const d = digits.charCodeAt(digits.length - 1 - i) - 48
74    c = VERHOEFF_D[c]![VERHOEFF_P[i % 8]![d]!]!
75  }
76  return digits.length > 0 && c === 0
77}
78
79function ibanValid(raw: string): boolean {
80  const iban = raw.replace(/ /g, '')
81  if (iban.length < 15 || iban.length > 34) return false
82  const moved = iban.slice(4) + iban.slice(0, 4)
83  let rem = 0
84  for (const ch of moved) {
85    const code = ch.charCodeAt(0)
86    const n = code >= 65 ? code - 55 : code - 48
87    rem = n > 9 ? (rem * 100 + n) % 97 : (rem * 10 + n) % 97
88  }
89  return rem === 1
90}
91
92const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
93
94function base64urlDecode(s: string): string | null {
95  let acc = 0
96  let bits = 0
97  let out = ''
98  for (const ch of s.replace(/=+$/, '')) {
99    let v = B64URL.indexOf(ch)
100    if (ch === '+') v = 62
101    else if (ch === '/') v = 63
102    if (v < 0) return null
103    acc = ((acc << 6) | v) & 0xffffff
104    bits += 6
105    if (bits >= 8) {
106      bits -= 8
107      out += String.fromCharCode((acc >> bits) & 0xff)
108    }
109  }
110  return out
111}
112
113function jwtHeaderHasAlg(token: string): boolean {
114  const decoded = base64urlDecode(token.slice(0, token.indexOf('.')))
115  if (decoded === null) return false
116  try {
117    const header = JSON.parse(decoded)
118    return header !== null && typeof header === 'object' && typeof header.alg === 'string'
119  } catch {
120    return false
121  }
122}
123
124const NOT_SECRETS = new Set([
125  'true', 'false', 'null', 'none', 'nil', 'undefined', 'changeme', 'password', 'passw0rd',
126  'secret', 'token', 'placeholder', 'redacted', 'example', 'default', 'required', 'optional',
127])
128
129function plausibleSecret(v: string): boolean {
130  const lower = v.toLowerCase()
131  if (NOT_SECRETS.has(lower)) return false
132  if (/^\$\{[^}]*\}$|^\$\(?[A-Za-z_]\w*\)?$|^%[A-Za-z_]\w*%$|^\{\{.*\}\}$|^<.*>$/.test(v)) return false
133  if (v.includes('${') || v.includes('{{')) return false
134  if (/^(?:\/|\.{1,2}\/|~\/|[A-Za-z]:\\|\.\w)/.test(v)) return false
135  if (/^(?:https?|file):\/\//i.test(lower)) return false
136  if (/^(?:process\.env|os\.environ|env\.|import\.meta\.env)/.test(v)) return false
137  if (/^(.)\1*$/.test(v)) return false
138  if (/x{4,}|\*{3,}|\.{3}|your[_-]?|example|placeholder|changeme|redacted|dummy|sample|insert[_-]?/i.test(v)) return false
139  return true
140}
141
142function notFiller(v: string): boolean {
143  return entropy(v) >= 2.5 && !/x{6,}|X{6,}|0{8,}|EXAMPLE/.test(v)
144}
145
146// template and build-time slots: {{secret}}, <token>, __API_KEY__, %s, and a bare `Name=` cut off before its value
147function placeholderShaped(v: string): boolean {
148  return /^(?:\{\{|<|__|%)|(?:\}\}|>|__)$|^[A-Za-z_]+=$/.test(v)
149}
150
151function quotedSecret(v: string): boolean {
152  // lowercase words joined by - _ . with no digit read as enum values ('same-origin', 'github_webhook')
153  return plausibleSecret(v) && !placeholderShaped(v) && entropy(v) >= 2.0 && !/^[a-z]+(?:[-_.][a-z]*)+$/.test(v)
154}
155
156function codeIdentifier(v: string): boolean {
157  const digits = v.replace(/\D/g, '').length
158  const upper = v.replace(/[^A-Z]/g, '').length
159  const letters = v.replace(/[^A-Za-z]/g, '').length
160  // word humps (Uint8Array, ToStringUtf8) keep capitals sparse; random strings are about half capitals
161  return /^[A-Za-z][A-Za-z0-9]*$/.test(v) && digits <= 2 && upper > 0 && upper * 3 <= letters
162}
163
164function codeExpression(v: string): boolean {
165  if (v.includes('?.') || codeIdentifier(v)) return true
166  // a member chain (jose.base64url.decode, result.Payload.Data.ToStringUtf8) whose parts read as names, not
167  // a dotted token whose parts are digit-heavy
168  const parts = v.split('.')
169  return parts.length > 1 && parts.every((p) => /^[A-Za-z_$][\w$]*$/.test(p) && p.replace(/\D/g, '').length <= 2)
170}
171
172function bareSecret(v: string): boolean {
173  if (!plausibleSecret(v) || placeholderShaped(v) || entropy(v) < 3.0) return false
174  // a code identifier or member chain with no digit (getToken, process.env.KEY, my-secret-name) is not a value
175  if (!/\d/.test(v) && /^[A-Za-z_$][\w$]*(?:[.\-][A-Za-z_$][\w$]*)*$/.test(v)) return false
176  return !codeExpression(v)
177}
178
179function keywordStart(text: string, at: number): boolean {
180  if (at === 0) return true
181  const prev = text.charAt(at - 1)
182  if (prev === ':') return false
183  if (!/[A-Za-z0-9]/.test(prev) || /\\[nrt]$/.test(text.slice(Math.max(0, at - 2), at))) return true
184  // inside a word only a camelCase hump counts (dbPassword, clientSecret), never the auth of OAuth
185  return /^[A-Z][a-z]/.test(text.slice(at, at + 2)) && !(/o/i.test(prev) && /^auth/i.test(text.slice(at, at + 4)))
186}
187
188function dbUrlHasRealPassword(url: string): boolean {
189  const password = /^[^:]+:\/\/[^:@\/]*:([^@\/]*)@/.exec(url)?.[1] ?? ''
190  return (
191    password.length > 0 &&
192    plausibleSecret(password) &&
193    !/^(?:pass|pwd|secret|password|%[sd]|\{\{.*\}\}|<.*>)$/i.test(password)
194  )
195}
196
197function notTemplated(text: string, at: number): boolean {
198  return text.slice(Math.max(0, at - 2), at) !== '{{'
199}
200
201const FILE_TLDS = new Set([
202  'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'pdf', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'css', 'json',
203  'html', 'md', 'txt', 'yaml', 'yml', 'xml', 'zip', 'gz', 'py', 'rb', 'go', 'rs', 'java', 'lock', 'map',
204])
205
206function emailPlausible(v: string): boolean {
207  const at = v.lastIndexOf('@')
208  const local = v.slice(0, at)
209  const tld = v.slice(v.lastIndexOf('.') + 1).toLowerCase()
210  return !local.endsWith('.') && !local.includes('..') && !FILE_TLDS.has(tld)
211}
212
213function digitsOf(v: string): string {
214  return v.replace(/\D/g, '')
215}
216
217function phonePlausible(v: string): boolean {
218  const d = digitsOf(v)
219  if (d.length < 10 || d.length > 15) return false
220  if (/^(\d)\1+$/.test(d)) return false
221  if (/^\d+$/.test(v) && v.startsWith('1') && (v.length === 10 || v.length === 13)) return false
222  if (/^\d{4}[-.\/]\d{2}[-.\/]\d{2}/.test(v)) return false
223  if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(v)) return false
224  return true
225}
226
227function cardPlausible(v: string): boolean {
228  const d = digitsOf(v)
229  if (d.length < 13 || d.length > 19) return false
230  if (/ /.test(v) && /-/.test(v)) return false
231  if (/^(\d)\1+$/.test(d)) return false
232  return luhn(d)
233}
234
235function ssnPlausible(v: string): boolean {
236  return !['078-05-1120', '219-09-9999', '123-45-6789'].includes(v)
237}
238
239export const RULES: readonly Rule[] = [
240  {
241    id: 'aws-access-key',
242    label: 'AWS_KEY',
243    kind: 'secret',
244    pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g,
245    verify: notFiller,
246    source: GITLEAKS,
247  },
248  {
249    id: 'aws-secret-key',
250    label: 'AWS_SECRET',
251    kind: 'secret',
252    pattern: /(?:aws[_.-]?secret[_.-]?(?:access[_.-]?)?key|secret[_.-]?access[_.-]?key)["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]?([A-Za-z0-9\/+]{40})(?![A-Za-z0-9\/+=])/gi,
253    group: 1,
254    verify: (v) => entropy(v) >= 3.5 && !/EXAMPLE/.test(v),
255    source: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html',
256  },
257  {
258    id: 'github-token',
259    label: 'GITHUB_TOKEN',
260    kind: 'secret',
261    pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{82,255})\b/g,
262    verify: notFiller,
263    source: GITLEAKS,
264  },
265  {
266    id: 'gitlab-pat',
267    label: 'GITLAB_TOKEN',
268    kind: 'secret',
269    pattern: /\bglpat-[A-Za-z0-9_-]{20,128}(?:\.[A-Za-z0-9_-]{2,128}){0,3}(?![\w-])/g,
270    verify: notFiller,
271    source: GITLEAKS,
272  },
273  {
274    id: 'slack-token',
275    label: 'SLACK_TOKEN',
276    kind: 'secret',
277    pattern: /\bxox[abprs]-[0-9]{8,14}-[A-Za-z0-9-]{10,250}(?![\w-])/g,
278    verify: notFiller,
279    source: GITLEAKS,
280  },
281  {
282    id: 'slack-webhook',
283    label: 'SLACK_WEBHOOK',
284    kind: 'secret',
285    pattern: /(?:https?:\/\/)?hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+\/]{43,56}(?![A-Za-z0-9+\/])/g,
286    verify: notFiller,
287    source: GITLEAKS,
288  },
289  {
290    id: 'stripe-live-key',
291    label: 'STRIPE_KEY',
292    kind: 'secret',
293    pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,247}\b/g,
294    verify: notFiller,
295    source: GITLEAKS,
296  },
297  {
298    id: 'sendgrid-key',
299    label: 'SENDGRID_KEY',
300    kind: 'secret',
301    pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![\w-])/g,
302    verify: notFiller,
303    source: GITLEAKS,
304  },
305  {
306    id: 'twilio-key',
307    label: 'TWILIO_KEY',
308    kind: 'secret',
309    pattern: /\bSK[0-9a-fA-F]{32}\b/g,
310    verify: notFiller,
311    source: GITLEAKS,
312  },
313  {
314    id: 'google-api-key',
315    label: 'GOOGLE_API_KEY',
316    kind: 'secret',
317    pattern: /\bAIza[0-9A-Za-z_-]{35}(?![\w-])/g,
318    verify: notFiller,
319    source: GITLEAKS,
320  },
321  {
322    id: 'google-oauth-secret',
323    label: 'GOOGLE_OAUTH_SECRET',
324    kind: 'secret',
325    pattern: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![\w-])/g,
326    verify: notFiller,
327    source: GITHUB_PATTERNS,
328  },
329  {
330    id: 'anthropic-key',
331    label: 'ANTHROPIC_KEY',
332    kind: 'secret',
333    pattern: /\bsk-ant-[a-z]{3,8}\d{2}-[A-Za-z0-9_-]{32,200}(?![\w-])/g,
334    verify: notFiller,
335    source: GITLEAKS,
336  },
337  {
338    id: 'openai-key',
339    label: 'OPENAI_KEY',
340    kind: 'secret',
341    pattern: /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,250}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}|[A-Za-z0-9]{48})(?![\w-])/g,
342    verify: notFiller,
343    source: GITLEAKS,
344  },
345  {
346    id: 'huggingface-token',
347    label: 'HF_TOKEN',
348    kind: 'secret',
349    pattern: /\bhf_[A-Za-z]{34}\b/g,
350    verify: notFiller,
351    source: GITLEAKS,
352  },
353  {
354    id: 'npm-token',
355    label: 'NPM_TOKEN',
356    kind: 'secret',
357    pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
358    verify: notFiller,
359    source: GITLEAKS,
360  },
361  {
362    id: 'pypi-token',
363    label: 'PYPI_TOKEN',
364    kind: 'secret',
365    pattern: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,1000}(?![\w-])/g,
366    source: GITLEAKS,
367  },
368  {
369    id: 'azure-storage-key',
370    label: 'AZURE_STORAGE_KEY',
371    kind: 'secret',
372    pattern: /(?:AccountKey|azure[_.-]?storage[_.-]?(?:account[_.-]?)?key|storage[_.-]?account[_.-]?key)["'`]?[ \t]{0,5}[=:][ \t]{0,5}["'`]?([A-Za-z0-9+\/]{86}==)/gi,
373    group: 1,
374    verify: notFiller,
375    source: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string',
376  },
377  {
378    id: 'jwt',
379    label: 'JWT',
380    kind: 'secret',
381    pattern: /\beyJ[A-Za-z0-9_-]{10,4096}\.ey[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{0,4096}(?![\w-])/g,
382    verify: jwtHeaderHasAlg,
383    source: 'https://datatracker.ietf.org/doc/html/rfc7519',
384  },
385  {
386    id: 'private-key',
387    label: 'PRIVATE_KEY',
388    kind: 'secret',
389    // the body never holds five dashes, so a BEGIN without an END stops at the next marker instead of scanning 16 KB
390    pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----(?:[^-]|-{1,4}(?!-)){16,65536}?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/g,
391    source: GITLEAKS,
392  },
393  {
394    id: 'private-key-truncated',
395    label: 'PRIVATE_KEY',
396    kind: 'secret',
397    // a key cut off before its END line; separators may be real or JSON-escaped newlines, lines may be indented.
398    // The base64 class has no newline, backslash or space, so each line can split only one way.
399    pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----[ \t]{0,16}(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}[A-Za-z][A-Za-z0-9-]{0,63}:[ \t][^\r\n\\]{0,256}){0,8}(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}(?=\r?\n|\\r\\n|\\n))?(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}[A-Za-z0-9+\/=]{16,128}){2,2048}(?![A-Za-z0-9+\/=])/g,
400    source: 'https://www.rfc-editor.org/rfc/rfc7468',
401  },
402  {
403    id: 'db-connection-url',
404    label: 'DB_URL',
405    kind: 'secret',
406    pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@\/'"`<>]{0,128}:[^\s@\/'"`<>]{1,256}@[^\s\/'"`<>?#@(){}\[\],;]{1,256}(?:[\/?#][^\s'"`<>(){}\[\],;]{0,1024})?/gi,
407    verify: dbUrlHasRealPassword,
408    before: notTemplated,
409    source: GITHUB_PATTERNS,
410  },
411  {
412    id: 'auth-header',
413    label: 'AUTH_HEADER',
414    kind: 'secret',
415    pattern: /\bAuthorization["']?[ \t]{0,5}[:=][ \t]{0,5}["']?(?:Bearer|Basic|Token)[ \t]{1,5}([A-Za-z0-9._~+\/=-]{8,4096})/gi,
416    group: 1,
417    verify: plausibleSecret,
418    source: 'https://www.rfc-editor.org/rfc/rfc9110#name-authorization',
419  },
420  {
421    id: 'generic-secret-quoted',
422    label: 'SECRET',
423    kind: 'secret',
424    pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]([^\s"'`]{8,256})["'`]/gi,
425    group: 1,
426    verify: quotedSecret,
427    before: keywordStart,
428    source: GITLEAKS,
429  },
430  {
431    id: 'generic-secret',
432    label: 'SECRET',
433    kind: 'secret',
434    // a literal \n, \r or \t (backslash and letter) ends the value, as in a dotenv string inside source code
435    pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}((?:[^\s"'`;,(){}<>\[\]\\]|\\(?![nrt])){8,256})(?!(?!\\[nrt])[^\s"'`;,(){}<>\[\]])/gi,
436    group: 1,
437    verify: bareSecret,
438    before: keywordStart,
439    source: GITLEAKS,
440  },
441  {
442    id: 'email',
443    label: 'EMAIL',
444    kind: 'pii',
445    // a colon or slash before the local part means URL userinfo (user:token@host), not an address
446    pattern: /(?:^|mailto:|[^\w.%+\/:-])([A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Za-z]{2,24})\b/gm,
447    group: 1,
448    verify: emailPlausible,
449    source: 'https://www.rfc-editor.org/rfc/rfc5322#section-3.4.1',
450  },
451  {
452    id: 'payment-card',
453    label: 'CARD',
454    kind: 'pii',
455    pattern: /(?:^|[^\w.+\/-])([2-6](?:[ -]?\d){12,18})(?![\w-]|[ -]\d)/gm,
456    group: 1,
457    verify: cardPlausible,
458    source: 'https://en.wikipedia.org/wiki/Luhn_algorithm',
459  },
460  {
461    id: 'aadhaar',
462    label: 'AADHAAR',
463    kind: 'pii',
464    pattern: /(?:^|[^\w.+\/-])([2-9]\d{3}([ -]?)\d{4}\2\d{4})(?![\w-]|[ -]\d)/gm,
465    group: 1,
466    verify: (v) => verhoeff(digitsOf(v)),
467    source: 'https://uidai.gov.in/en/my-aadhaar/about-your-aadhaar.html',
468  },
469  {
470    id: 'us-ssn',
471    label: 'SSN',
472    kind: 'pii',
473    pattern: /(?:^|[^\w.\/-])((?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4})(?![\w-]|\.\d)/gm,
474    group: 1,
475    verify: ssnPlausible,
476    source: 'https://www.ssa.gov/employer/randomization.html',
477  },
478  {
479    id: 'iban',
480    label: 'IBAN',
481    kind: 'pii',
482    pattern: /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g,
483    verify: ibanValid,
484    source: 'https://en.wikipedia.org/wiki/International_Bank_Account_Number',
485  },
486  {
487    id: 'india-pan',
488    label: 'PAN',
489    kind: 'pii',
490    pattern: /\b[A-Z]{3}[ABCFGHLJPT][A-Z]\d{4}[A-Z]\b/g,
491    source: 'https://en.wikipedia.org/wiki/Permanent_account_number',
492  },
493  {
494    id: 'phone',
495    label: 'PHONE',
496    kind: 'pii',
497    pattern: /(?:^|[^\w+.\/@#-])(\+[1-9]\d{9,14}|\+[1-9]\d{0,2}[ .-]?\(?\d{1,4}\)?(?:[ .-]\d{2,5}){1,4}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}([.-])\d{3}\2\d{4}|[6-9]\d{4}[ -]\d{5}|0\d{2,4}[ -]\d{3,4}[ -]\d{3,4})(?![\w-]|[ .)]\d)/gm,
498    group: 1,
499    verify: phonePlausible,
500    source: 'https://www.itu.int/rec/T-REC-E.164',
501  },
502]
503
504const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]{1,64}#[0-9a-fA-F]{1,64}\]/g
505
506type Compiled = { re: RegExp; indexed: boolean }
507const compiled = new Map<RegExp, Compiled>()
508
509function compile(pattern: RegExp): Compiled {
510  let c = compiled.get(pattern)
511  if (c) return c
512  const flags = pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'
513  try {
514    c = { re: new RegExp(pattern.source, flags.includes('d') ? flags : flags + 'd'), indexed: true }
515  } catch {
516    c = { re: new RegExp(pattern.source, flags), indexed: false }
517  }
518  compiled.set(pattern, c)
519  return c
520}
521
522type Ranked = Hit & { order: number }
523
524// Rules anchored on a fixed prefix or marker. Only these run on decoded base64: the generic and PII rules
525// would fire on arbitrary decoded prose.
526const WRAPPABLE = new Set([
527  'aws-access-key', 'github-token', 'gitlab-pat', 'slack-token', 'slack-webhook', 'stripe-live-key', 'sendgrid-key',
528  'twilio-key', 'google-api-key', 'google-oauth-secret', 'anthropic-key', 'openai-key', 'huggingface-token',
529  'npm-token', 'pypi-token', 'private-key', 'private-key-truncated', 'db-connection-url', 'jwt',
530])
531const B64_MIN = 24
532const B64_MAX = 8192
533const B64_RUNS = 200
534
535const B64_VALUE = new Int8Array(128).fill(-1)
536for (let i = 0; i < 64; i++) B64_VALUE[B64URL.charCodeAt(i)] = i
537B64_VALUE[43] = 62
538B64_VALUE[47] = 63
539
540function b64Value(code: number): number {
541  return code < 128 ? B64_VALUE[code]! : -1
542}
543
544// Maximal runs of the base64 alphabet (standard or url-safe, never mixed) with optional padding.
545// Padded or standard runs must be a multiple of 4 long; unpadded url-safe runs only need a valid tail.
546function base64Runs(text: string): [number, number][] {
547  const runs: [number, number][] = []
548  let i = 0
549  while (i < text.length && runs.length < B64_RUNS) {
550    if (b64Value(text.charCodeAt(i)) < 0) {
551      i++
552      continue
553    }
554    let j = i
555    let std = false
556    let url = false
557    for (; j < text.length; j++) {
558      const c = text.charCodeAt(j)
559      if (b64Value(c) < 0) break
560      if (c === 43 || c === 47) std = true
561      else if (c === 45 || c === 95) url = true
562    }
563    let k = j
564    while (k < text.length && k - j < 2 && text.charCodeAt(k) === 61) k++
565    const body = j - i
566    const total = k - i
567    const shaped = k > j ? total % 4 === 0 : body % 4 === 0 || (!std && body % 4 !== 1)
568    if (total >= B64_MIN && total <= B64_MAX && !(std && url) && shaped) runs.push([i, k])
569    i = k
570  }
571  return runs
572}
573
574// Decodes text[start, end) to a byte string, or null once more than a tenth of the bytes are not printable ASCII.
575function decodePrintable(text: string, start: number, end: number): string | null {
576  let stop = end
577  while (stop > start && text.charCodeAt(stop - 1) === 61) stop--
578  const maxBad = Math.floor(((stop - start) * 3) / 40)
579  let bad = 0
580  let acc = 0
581  let bits = 0
582  let out = ''
583  for (let i = start; i < stop; i++) {
584    acc = ((acc << 6) | b64Value(text.charCodeAt(i))) & 0xffffff
585    bits += 6
586    if (bits >= 8) {
587      bits -= 8
588      const byte = (acc >> bits) & 0xff
589      if (!(byte >= 0x20 && byte <= 0x7e) && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && ++bad > maxBad) return null
590      out += String.fromCharCode(byte)
591    }
592  }
593  return out
594}
595
596function firstWrappedRule(decoded: string, off: ReadonlySet<string> | undefined): number {
597  for (let order = 0; order < RULES.length; order++) {
598    const rule = RULES[order]!
599    if (!WRAPPABLE.has(rule.id) || off?.has(rule.id) || off?.has(`${rule.id}-base64`)) continue
600    const { re } = compile(rule.pattern)
601    const g = rule.group ?? 0
602    re.lastIndex = 0
603    for (let m = re.exec(decoded); m !== null; m = re.exec(decoded)) {
604      const raw = m[g]
605      if (raw && (!rule.before || rule.before(decoded, m.index)) && (!rule.verify || rule.verify(raw))) {
606        re.lastIndex = 0
607        return order
608      }
609      re.lastIndex = m.index + 1
610    }
611    re.lastIndex = 0
612  }
613  return -1
614}
615
616export function scan(text: string, opts: { pii?: boolean; off?: ReadonlySet<string> } = {}): Hit[] {
617  const spans: [number, number][] = []
618  PLACEHOLDER.lastIndex = 0
619  let masked = text
620  for (let m = PLACEHOLDER.exec(text); m !== null; m = PLACEHOLDER.exec(text)) {
621    spans.push([m.index, m.index + m[0].length])
622  }
623  if (spans.length > 0) {
624    // spaces keep every offset stable while no rule can match through a placeholder
625    let out = ''
626    let at = 0
627    for (const [s, e] of spans) {
628      out += text.slice(at, s) + ' '.repeat(e - s)
629      at = e
630    }
631    masked = out + text.slice(at)
632  }
633
634  const found: Ranked[] = []
635  RULES.forEach((rule, order) => {
636    if (rule.kind === 'pii' && !opts.pii) return
637    if (opts.off?.has(rule.id)) return
638    const { re, indexed } = compile(rule.pattern)
639    const g = rule.group ?? 0
640    re.lastIndex = 0
641    for (let m = re.exec(masked); m !== null; m = re.exec(masked)) {
642      if (m[0] === '') {
643        re.lastIndex++
644        continue
645      }
646      const raw = m[g]
647      if (raw === undefined || raw === '') continue
648      const start = indexed ? (m.indices?.[g]?.[0] ?? -1) : m.index + m[0].lastIndexOf(raw)
649      if (start < 0) continue
650      const end = start + raw.length
651      const value = text.slice(start, end)
652      const inPlaceholder = spans.some(([s, e]) => start < e && end > s)
653      if (inPlaceholder || (rule.before && !rule.before(masked, m.index)) || (rule.verify && !rule.verify(value))) {
654        re.lastIndex = m.index + 1
655        continue
656      }
657      found.push({ id: rule.id, label: rule.label, kind: rule.kind, start, end, value, order })
658    }
659    re.lastIndex = 0
660  })
661
662  // one bounded decode pass: a base64 run whose decoded text trips a prefix rule is redacted whole
663  for (const [start, end] of base64Runs(masked)) {
664    const decoded = decodePrintable(masked, start, end)
665    if (decoded === null) continue
666    const order = firstWrappedRule(decoded, opts.off)
667    if (order < 0) continue
668    const rule = RULES[order]!
669    found.push({ id: `${rule.id}-base64`, label: rule.label, kind: rule.kind, start, end, value: text.slice(start, end), order })
670  }
671
672  found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order)
673  const hits: Hit[] = []
674  let lastEnd = -1
675  for (const h of found) {
676    if (h.start < lastEnd) continue
677    hits.push({ id: h.id, label: h.label, kind: h.kind, start: h.start, end: h.end, value: h.value })
678    lastEnd = h.end
679  }
680  return hits
681}
682
types/index.d.ts 11 lines
1declare module 'claude-code' {
2  interface PluginState {
3    redact: {
4      vault: Record<string, string>
5      salt: string
6      counts: Record<string, number>
7      restored: number
8    }
9  }
10}
11