SLOPSHOPPER

secret-guard

Keeps API keys, cloud credentials and other secrets out of what is sent to the model: every text that enters the conversation is scanned and each match is…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ secret-guard: redacted 2 (stripe secret │ ⎿ Read 6 lines │ key, password) in tool result · Bash cat │ ⏺ Update(src/auth.ts) │ .env │ ⎿ 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 › /secret-guard ⎿ secret-guard: secret-guard: enabled ⎿ secret-guard: scanning: prompts, context, attachments and tool results ⎿ secret-guard: redacted this session: 2 ⎿ secret-guard: by label: ⎿ secret-guard: stripe secret key: 1 ⎿ secret-guard: password: 1 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🛡 secret-guard · 2 redacted this session · last: password #8604b47b · Bash cat .env · 08:53 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🛡 secret-guard · 2 redacted this session · last: password #8604b47b · Bash cat .env · 08:53 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

secret-guard

Keeps API keys, cloud credentials and other secrets out of what is sent to the model. Every text that enters the conversation is scanned for known secret shapes; each match is replaced with a meaningful placeholder, a toast says what was redacted, and the band keeps a count. The value itself is never logged, stored or shown.

Before (what Claude would have read):

$ cat .env
GOOGLE_API_KEY=REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER
DATABASE_URL=postgres://app:REDACTED@db.internal:5432/app

After (what Claude reads):

$ cat .env
GOOGLE_API_KEY=<google api key>
DATABASE_URL=postgres://app:REDACTED@db.internal:5432/app
🛡 secret-guard · 2 redacted this session · last: github token #ea21bbd9 · Read .env:4 · 14:02

Where it hooks, and why

HookWhat passes through it
session.appendEvery row a conversation keeps, before it is stored: your prompt, a slash command's output, every tool result (file contents, command output, web pages), delivered messages, injected notes, compaction summaries. The main conversation and every subagent's alike. The engine stores what the chain answers, so the model's next request carries the redacted row. The model's own response blocks and notices it never reads are left alone.
prompt.submitYour message, so it is shown and stored redacted.
prompt.contextThe context blocks the first message carries (CLAUDE.md and friends).
prompt.attachmentTexts the engine injects on its own: a mentioned file, a reminder, a settings hook's output.

session.append is the choke point: it is the one place through which a tool result or a subagent's row reaches the model, which is why a Read of a key file or a cat .env in a sub agent is covered without hooking each tool.

What it detects

PlaceholderMatches
<gcp service account private key>, <gcp service account private key id>the two key fields of a "type": "service_account" JSON file; client_email and project_id stay readable
<private key>any PEM private key block (RSA, EC, DSA, OPENSSH, PGP, encrypted; real or \n-escaped newlines); certificates are not secrets
<google api key>AIza… (39 characters)
<google oauth access token>, <google oauth refresh token>, <google oauth client secret>ya29.…, 1//0…, GOCSPX-…
<aws access key id>AKIA…, ASIA…, ABIA…, ACCA…, A3T… (20 characters)
<aws secret access key>a 40-character value next to aws_secret_access_key / secretAccessKey
<anthropic api key>, <openai api key>sk-ant-…, sk-… / sk-proj-…
<github token>, <gitlab token>ghp_ / gho_ / ghu_ / ghs_ / ghr_, github_pat_…, glpat-…
<npm token>, <pypi token>, <hugging face token>npm_…, pypi-AgEIcHlwaS5vcmc…, hf_…
<slack token>, <slack webhook url>xox[abposre]-…, https://hooks.slack.com/services/T…/B…/…
<telegram bot token>, <discord bot token>123456789:AA…, the three-part Discord shape
<stripe secret key>, <sendgrid api key>, <twilio api key>sk_live_ / sk_test_ / rk_…, SG.….…, SK + 32 hex
<jwt>eyJ….eyJ….…
<bearer token>, <basic auth>, <token>the value after `Authorization: BearerBasicToken`
<api key>the value after x-api-key: / api-key: / apikey:
<password>the password in scheme://user:password@host
<secret value>the value of api_key = …, password: …, "client_secret": "…", export TOKEN=… and the like (names: api key, secret key, client secret, access / auth / refresh token, private key, password, passwd, pwd, token, secret), 8+ characters, when it does not look like a placeholder, an environment reference, code, a path or a URL
<high-entropy secret>a random-looking token of 32+ characters with Shannon entropy ≥ 4.0 bits/char that sits within 60 characters after a secret-ish word (key, secret, token, password, credential, auth, signature, bearer, …); hex hashes, UUIDs and base64 data URIs are excluded

Detectors run in that order, specific before generic, and redact() is idempotent: a placeholder never matches a detector, so a row that is scanned twice (the prompt passes prompt.submit and then session.append) is rewritten once.

Placeholders are English in every UI language: the model reads them and they must be stable. With keep_hint on, the last four characters ride along (<google api key …Zx3f>) so two keys can be told apart; off by default.

Usage

Installed, it is on. The band above the prompt appears once something has been redacted: one line in its own rounded frame, stacked with the other mods' frames; the frame turns yellow while the mod is paused or a custom pattern did not compile (band_style).

CommandDoes
/secret-guardstatus: enabled / paused, what is scanned, totals by label, and the last 20 hits, one per line (see below)
/secret-guard logevery hit recorded this session (up to 100), grouped by fingerprint: the same secret seen in several places is one group
/secret-guard clearforgets the hit list and the counters of this session
/secret-guard off / onpause / resume for this session (the enabled setting is the permanent switch)
/secret-guard testruns every detector over a built-in sample of obviously fake values and prints which labels fired; a self-test, no real secrets involved

The hit record

Each redaction is recorded for this session, to help you find where secrets live and whether the same one turns up in several places. Nothing is kept past the session, nothing is written to disk, and the value itself is never stored, shown or logged.

14:02  aws secret access key  #a3f91c02  Read ~/proj/.env:4
14:02  github token           #77be1d40  Read ~/proj/.env:6
14:05  aws secret access key  #a3f91c02  Bash cat ~/proj/.env:2 [agent 3fa9c1d2]
14:07  google api key         #0b5e7a91  prompt
FieldWhat it is
timewhen the row passed
labelthe placeholder's label
fingerprint# + the first 8 hex digits of HMAC-SHA256 over the value, keyed with 32 random bytes made when the session starts (kept in the session's $.state so a hot reload keeps fingerprints stable, never in $.store). The same value gets the same fingerprint within the session; a different session has a different key, so fingerprints cannot be compared across sessions, and without the key a short password cannot be brute-forced from its fingerprint. Off with fingerprints = false.
tool and sourcefor a tool result: the tool and what the call was about, a file path for tools that name one (Read, Edit, Grep with a path, NotebookEdit), else the Bash command or the Grep pattern. A tool.call hook remembers this per tool_use_id and passes the call through untouched; the source is itself redacted and cut to 120 characters, and it is never the tool's output. For your prompt it says prompt; for context it names the block (claudeMd); for an attachment its type
linethe line of the match in the scanned text: Read's own line numbers when the output carries them, otherwise counted from the start of that text (for Bash, a line of the output, not of a file)
agentthe subagent whose conversation carried it, when it was not the main one

The toast names the source too: secret-guard: redacted 2 (aws secret access key, github token) in tool result · Read .env.

Settings

SettingDefaultMeaning
languageautoUI language: auto (from LC_ALL / LC_MESSAGES / LANG), en, zh-TW, ja
enabledtrueOff: nothing is scanned or rewritten
keep_hintfalseKeep the last 4 characters in the placeholder
entropy_backstoptrueThe high-entropy detector
custom_patternsemptyExtra detectors, one per line as label=regex (JavaScript regex; /…/i form accepted; g is added). An invalid line is shown once in the band and in the status, and ignored.
allow_patternsemptyOne regex per line; a match that also matches one of these is left alone. The AWS documentation example pair (REDACTED-AWS-ACCESS-KEY-BY-SLOPSHOPPER and its secret) is always allowed.
scan_tool_resultstrueOff: only your prompts, slash-command rows and the context blocks are scanned; tool results, attachments, deliveries, notes and compaction summaries pass through
fingerprintstrueRecord a per-session HMAC fingerprint of each redacted value (see "The hit record"); off records the hit without one
band_styleboxHow the band line is framed: box (a rounded frame, dim normally and yellow while paused or with an invalid custom pattern), rule (a thin line beneath it), plain (text only)

Set them with /plugin configure secret-guard@cockpit.

What it does not cover

  • Text already in the context before the mod loaded, and anything the model read in earlier turns.
  • The screen and the transcript's structured record. The terminal may draw a tool result just before its rewrite, and a tool's structured record (toolUseResult) is stored as the tool made it: the transcript file on disk can still hold the raw value even though the model never reads it. Treat transcript files as sensitive regardless.
  • Model output. If the model reproduces a secret it already knows, that is not scanned.
  • Images and documents, secrets split across lines or obfuscated (base64-wrapped, reversed, in a screenshot), and shapes the table does not know.
  • The prompt.context rewrite makes the engine forget which files were behind the claudeMd block (its documented rule for a rewritten text); only the files list is affected, the text is still sent.

This is a safety net, not data-loss prevention. Keep secrets out of the repository and the shell history, use a secret manager, and rotate anything that was pasted by mistake.

False positives and how to allowlist

The generic <secret value> rule is the one most likely to fire on something harmless, such as a password-shaped test fixture. Add a regex to allow_patterns that matches the fixture, or turn the value into an obvious placeholder (<your-password>, ${PASSWORD}, xxx), which the rule skips. Hex hashes, UUIDs, git SHAs, package integrity hashes and ordinary base64 blobs are excluded from the entropy backstop; if it still fires on something, entropy_backstop turns it off without losing the specific detectors.

What it does before you install it

claude plugin validate ./plugins/secret-guard

Result (v0.1.0, Claude Code 2.1.289):

hooks: session.start, prompt.submit, tool.call, session.append, prompt.context, prompt.attachment,
       command.run{command=secret-guard}, ui.render{component=AbovePrompt}
calls: $.clock.now, $.command.register, $.env.get, $.state.get, $.state.set, $.ui.resolve, $.ui.toast
env reads: HOME, LANG, LC_ALL, LC_MESSAGES · env writes: nothing

No $.fs, no $.process, no $.http, no model calls: the mod reads nothing but the rows that pass through it and writes nothing but their rewrite. The tool.call hook only notes what each call is about and passes it on unchanged. What it keeps in $.state is counts, labels, where a redaction happened, fingerprints and the session's fingerprint key, never a value; the same goes for toasts and the debug log. HOME is read only to shorten paths to ~ in the list.

Limits

  • Detection is by shape. A secret in an unusual format, cut across two lines, or encoded is not seen.
  • Everything runs on every row the model reads, in the main conversation and in subagents; the regexes are linear and the cost is negligible next to a model request, but a very large tool result (megabytes) is scanned in full.
  • The band is one line; the status command has the detail.
  • The hit list holds the newest 100 hits; /secret-guard clear empties it.
  • A path written near a word like secret is not taken for a secret: the entropy backstop skips rooted tokens with three or more /, and relative ones with three or more / whose segments are all lowercase words (plugins/secret-guard/hooks/logic.ts). A base64 secret that happens to start with / and contain three / would be missed by that one detector.
  • Only text blocks and tool_result blocks are rewritten. Thinking, tool_use, image and document blocks are pinned by the engine or carry no text.

Development

claude plugin validate ./plugins/secret-guard
claude plugin test ./plugins/secret-guard

The tests build every fixture from pieces ('AIza' + 'A'.repeat(35)) so the test file itself never contains a credential-shaped literal. The session.append decision is the pure planAppend() in hooks/logic.ts, because the test kit cannot raise that event itself; the hook in hooks/register.tsx only adds the state and toast calls around it.

Source 4 files
hooks/register.tsx 317 lines
1// secret-guard: keeps API keys, cloud credentials and other secrets out of what the
2// model reads.
3//
4// - `session.append` is the choke point: every row a conversation keeps (the person's
5//   prompt, a command's output, a tool result, a delivered message, an injected note,
6//   a compaction summary; in the main conversation and in every subagent's) passes
7//   it once before it is stored, and the bottom stores what the chain answered. The
8//   model's own response blocks and notices it never reads are left alone.
9// - `prompt.submit` redacts the person's message before it is shown and stored;
10//   `prompt.context` the context blocks (CLAUDE.md and friends); `prompt.attachment`
11//   the texts the engine injects on its own (a mentioned file, a reminder).
12// - A toast names what was redacted (the label, never the value) and where it came
13//   from; the band keeps a count; `/secret-guard` shows the status, `log` lists every
14//   hit by fingerprint, `clear` forgets them, `off` / `on` pause and resume, `test`
15//   runs a self-test.
16// - Each hit is recorded for this session only: label, door, tool and source (a
17//   `tool.call` hook remembers what each tool_use_id was about), line, and an HMAC
18//   fingerprint under a random key made for this session. Never the value.
19// - No files, no processes, no network, no model calls. Any failure passes the row
20//   through unchanged: the mod never blocks a conversation.
21
22import { atom, read, update } from 'claude-code'
23import type { Register } from 'claude-code'
24
25import type { RecentHit } from '../types'
26import type { Lang } from './i18n'
27import { DEFAULT_LANG, resolveLang, t } from './i18n'
28import {
29  addByLabel,
30  bandText,
31  fingerprint,
32  hitRecords,
33  logText,
34  newFingerprintKey,
35  planAppend,
36  pushRecent,
37  readSettings,
38  redact,
39  selfTestText,
40  statusText,
41  toastText,
42  toolSource,
43} from './logic'
44import type { AppendRow, BandStyle, Found, Hit, Settings, ToolInfo } from './logic'
45
46const langState = atom({ plugin: 'secret-guard', key: 'lang' } as const, DEFAULT_LANG)
47const isPaused = atom({ plugin: 'secret-guard', key: 'isPaused' } as const, false)
48const total = atom({ plugin: 'secret-guard', key: 'total' } as const, 0)
49const byLabel = atom({ plugin: 'secret-guard', key: 'byLabel' } as const, {})
50const recent = atom({ plugin: 'secret-guard', key: 'recent' } as const, [] as RecentHit[])
51const badPatterns = atom({ plugin: 'secret-guard', key: 'badPatterns' } as const, [] as string[])
52const fpKeyState = atom({ plugin: 'secret-guard', key: 'fpKey' } as const, '')
53
54// Module state: re-read from the options on every (re)load.
55let settings: Settings = readSettings(undefined)
56let lang: Lang = DEFAULT_LANG
57let home: string | null = null
58/** This session's fingerprint key (hex); restored from $.state after a hot reload */
59let fpKey = ''
60/** What each tool call was about, by tool_use_id, so a tool result's hits can name it */
61const toolCalls = new Map<string, ToolInfo>()
62const TOOL_CALLS_MAX = 500
63
64function toast($: any, text: string): void {
65  try {
66    $.ui.toast(text)
67  } catch {}
68}
69
70async function resolveLanguage($: any): Promise<Lang> {
71  const env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string } = {}
72  try {
73    env.LC_ALL = await $.env.get('LC_ALL')
74    env.LC_MESSAGES = await $.env.get('LC_MESSAGES')
75    env.LANG = await $.env.get('LANG')
76  } catch {}
77  return resolveLang(settings.language, env)
78}
79
80/** Whether anything is scanned right now: enabled in settings and not paused for the session. */
81async function isActive($: any): Promise<boolean> {
82  if (!settings.enabled) return false
83  return !(await read($, isPaused))
84}
85
86/** The fingerprint key for this session: the one in $.state (a hot reload keeps it), else a new one. */
87async function ensureFpKey($: any): Promise<string> {
88  if (fpKey) return fpKey
89  const kept = await read($, fpKeyState)
90  fpKey = typeof kept === 'string' && kept.length === 64 ? kept : newFingerprintKey()
91  if (fpKey !== kept) await update($, fpKeyState, () => fpKey)
92  return fpKey
93}
94
95/**
96 * Counts a redaction, remembers where it happened and its fingerprint (never the
97 * value), and tells the person. `found` carries the values only until they are
98 * fingerprinted here; they go no further.
99 */
100async function record($: any, hits: Hit[], found: Found[], where: string, agent: string | null, source?: string): Promise<void> {
101  if (!hits.length) return
102  const now: number = await $.clock.now()
103  const n = hits.reduce((s, h) => s + h.count, 0)
104  const prints: (string | undefined)[] = []
105  if (settings.fingerprints) {
106    try {
107      const key = await ensureFpKey($)
108      for (const f of found) prints.push(await fingerprint(key, f.value))
109    } catch {
110      prints.length = 0
111    }
112  }
113  const items = hitRecords(found, prints, { where, agent, at: now, source, lookup: id => toolCalls.get(id) })
114  await update($, total, v => v + n)
115  await update($, byLabel, m => addByLabel(m, hits))
116  await update($, recent, list => pushRecent(list, items))
117  const first = items.find(i => i.tool || i.source)
118  toast($, toastText(lang, hits, where, agent, first ? { tool: first.tool, source: first.source } : undefined))
119}
120
121export const register: Register = (on, options) => {
122  settings = readSettings(options as Record<string, unknown> | undefined)
123
124  on('session.start', async ($, e, next) => {
125    const out = await next(e)
126    lang = await resolveLanguage($)
127    await update($, langState, () => lang)
128    try {
129      home = (await $.env.get('HOME')) ?? null
130    } catch {}
131    try {
132      await ensureFpKey($)
133    } catch {}
134    await update($, badPatterns, () => settings.badPatterns)
135    await $.command.register({
136      name: 'secret-guard',
137      description: t(lang, 'cmd.description'),
138      argumentHint: '[log|clear|off|on|test]',
139    })
140    return out
141  })
142
143  // The person's own message, before it is shown and stored
144  on('prompt.submit', async ($, e, next) => {
145    try {
146      if (!(await isActive($))) return next(e)
147      const r = redact(e.text, settings)
148      if (!r.hits.length) return next(e)
149      const out = await next({ ...e, text: r.text })
150      await record($, r.hits, r.found, 'prompt', null, 'prompt')
151      return out
152    } catch {
153      return next(e)
154    }
155  })
156
157  // Remembers what each tool call is about (a path, a command, a pattern), so the hits
158  // in its result can say where they came from; the call itself passes untouched
159  on('tool.call', async ($, e, next) => {
160    try {
161      const id = e.tool_use_id
162      if (typeof id === 'string' && id) {
163        const source = toolSource(String(e.tool), e as unknown as Record<string, unknown>, settings)
164        toolCalls.set(id, source === undefined ? { tool: String(e.tool) } : { tool: String(e.tool), source })
165        if (toolCalls.size > TOOL_CALLS_MAX) {
166          const oldest = toolCalls.keys().next().value
167          if (oldest !== undefined) toolCalls.delete(oldest)
168        }
169      }
170    } catch {}
171    return next(e)
172  })
173
174  // Every row a conversation keeps, main or subagent, before it is stored
175  on('session.append', async ($, e, next) => {
176    try {
177      if (!(await isActive($))) return next(e)
178      const plan = planAppend(e as AppendRow, settings)
179      if (plan.kind === 'pass') return next(e)
180      const out = await next(plan.input as typeof e)
181      await record($, plan.hits, plan.found, plan.where, plan.agent)
182      return out
183    } catch {
184      return next(e)
185    }
186  })
187
188  // The context blocks the first message carries (CLAUDE.md, the attached project, ...)
189  on('prompt.context', async ($, e, next) => {
190    const out = await next(e)
191    try {
192      if (!(await isActive($))) return out
193      const redacted: { name: string; hits: Hit[]; found: Found[] }[] = []
194      const blocks = out.blocks.map(b => {
195        const r = redact(b.text, settings)
196        if (!r.hits.length) return b
197        redacted.push({ name: b.name, hits: r.hits, found: r.found })
198        return { ...b, text: r.text }
199      })
200      if (!redacted.length) return out
201      for (const r of redacted) await record($, r.hits, r.found, 'context', null, r.name)
202      return { ...out, blocks }
203    } catch {
204      return out
205    }
206  })
207
208  // The texts the engine injects on its own (a mentioned file, a reminder, a hook's output)
209  on('prompt.attachment', async ($, e, next) => {
210    const out = await next(e)
211    try {
212      if (!(await isActive($)) || typeof out.text !== 'string') return out
213      const r = redact(out.text, settings)
214      if (!r.hits.length) return out
215      const kind = (e as { type?: unknown }).type
216      await record($, r.hits, r.found, 'attachment', (e as { agentId?: string }).agentId ?? null, typeof kind === 'string' ? kind : undefined)
217      return { ...out, text: r.text }
218    } catch {
219      return out
220    }
221  })
222
223  on('command.run', { command: 'secret-guard' }, async ($, e) => {
224    const arg = String(e.args ?? '').trim()
225    if (arg === 'off') {
226      await update($, isPaused, () => true)
227      return { text: t(lang, 'cmd.off') }
228    }
229    if (arg === 'on') {
230      await update($, isPaused, () => false)
231      return { text: t(lang, 'cmd.on') }
232    }
233    if (arg === 'test') return { text: selfTestText(lang, settings) }
234    if (arg === 'log') return { text: logText(lang, await read($, recent), home) }
235    if (arg === 'clear') {
236      await update($, total, () => 0)
237      await update($, byLabel, () => ({}))
238      await update($, recent, () => [])
239      return { text: t(lang, 'cmd.cleared') }
240    }
241    if (arg !== '') return { text: t(lang, 'cmd.usage') }
242    return {
243      text: statusText(lang, {
244        enabled: settings.enabled,
245        isPaused: await read($, isPaused),
246        total: await read($, total),
247        byLabel: await read($, byLabel),
248        recent: await read($, recent),
249        badPatterns: await read($, badPatterns),
250        scanToolResults: settings.scanToolResults,
251        now: await $.clock.now(),
252        home,
253      }),
254    }
255  })
256
257  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258    if (e.props.hasSurvey) return next(e)
259    const l = (await read($, langState)) as Lang
260    const text = bandText(l, {
261      enabled: settings.enabled,
262      isPaused: await read($, isPaused),
263      total: await read($, total),
264      recent: await read($, recent),
265      badPatterns: await read($, badPatterns),
266    })
267    if (text === null) return next(e)
268    const ui = $.ui.resolve(e)
269    const { Text } = ui
270    // AbovePrompt is a hook chain: draw our line in its frame, then what the plugins beneath drew
271    const below = await next(e)
272    const paused = await read($, isPaused)
273    const isWarning = paused || (await read($, badPatterns)).length > 0
274    return frameBand(
275      ui,
276      settings.bandStyle,
277      isWarning,
278      e.props.bodyColumns,
279      <Text wrap="truncate-end" dimColor={!paused} color={paused ? 'yellow' : undefined}>
280        {text}
281      </Text>,
282      below,
283    )
284  })
285}
286
287/**
288 * Frames this mod's band content per `band_style` and stacks the plugins beneath under it.
289 * `box`: a rounded frame (yellow when `isWarning`, here while paused or when a custom pattern did not compile);
290 * `rule`: a dim line beneath, only when another plugin drew something below; `plain`: the bare text.
291 */
292function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
293  const { Box, Text } = ui
294  const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
295  const own =
296    style === 'box' ? (
297      <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
298        {content}
299      </Box>
300    ) : style === 'rule' ? (
301      <Box key="frame" flexDirection="column">
302        {content}
303        {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
304      </Box>
305    ) : (
306      <Box key="frame" flexDirection="column">
307        {content}
308      </Box>
309    )
310  return (
311    <Box flexDirection="column">
312      {own}
313      {below}
314    </Box>
315  )
316}
317
hooks/i18n.ts 178 lines
1// secret-guard i18n: the UI language, how it is resolved, and every string a person
2// reads, in English, Traditional Chinese and Japanese.
3// Pure: no `$`. Shared with logic.ts, register.tsx and the tests.
4//
5// The placeholders that replace a secret (`<google api key>`, `<private key>`, ...)
6// are NOT translated: the model reads them, and they must look the same in every
7// language so a placeholder is never mistaken for a value.
8
9export type Lang = 'en' | 'zh-TW' | 'ja'
10export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
11export const DEFAULT_LANG: Lang = 'en'
12
13export type LangEnv = { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }
14
15/**
16 * Picks the language: an explicit option (`en`, `zh-TW`, `ja`) wins; `auto`,
17 * undefined or anything else reads LC_ALL, then LC_MESSAGES, then LANG.
18 * Any `zh*` locale maps to zh-TW (only Traditional is shipped), `ja*` to ja,
19 * everything else (including C, POSIX and empty) to en.
20 */
21export function resolveLang(option: unknown, env: LangEnv): Lang {
22  if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
23  for (const raw of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
24    const v = (raw ?? '').trim()
25    if (!v) continue
26    const low = v.toLowerCase()
27    if (low === 'c' || low === 'posix') return 'en'
28    if (low.startsWith('zh')) return 'zh-TW'
29    if (low.startsWith('ja')) return 'ja'
30    return 'en'
31  }
32  return DEFAULT_LANG
33}
34
35export type Params = Record<string, string | number>
36type Message = string | ((p: Params) => string)
37
38const en = {
39  // toast
40  'toast.redacted': (p: Params) => `secret-guard: redacted ${p.n} (${p.labels})`,
41  'toast.redactedWhere': (p: Params) => `secret-guard: redacted ${p.n} (${p.labels}) in ${p.where}`,
42  // band
43  'band.summary': (p: Params) => `🛡 secret-guard · ${p.n} redacted this session · last: ${p.last}`,
44  'band.paused': '🛡 secret-guard · paused (/secret-guard on resumes)',
45  'band.badPatterns': (p: Params) => `🛡 secret-guard · ${p.n} invalid custom pattern(s) ignored: ${p.names}`,
46  // where the text came in
47  'where.prompt': 'prompt',
48  'where.command': 'command',
49  'where.context': 'context',
50  'where.attachment': 'attachment',
51  'where.tool-result': 'tool result',
52  'where.tool-message': 'tool message',
53  'where.delivery': 'delivery',
54  'where.hook-context': 'hook context',
55  'where.note': 'note',
56  'where.compaction': 'compaction',
57  'where.agent': (p: Params) => `${p.where}, agent ${p.id}`,
58  // command
59  'cmd.description': 'Secret redaction status; log lists every hit by fingerprint; clear empties it; off / on pause or resume for this session; test runs the detectors over a built-in sample',
60  'cmd.status.enabled': 'secret-guard: enabled',
61  'cmd.status.paused': 'secret-guard: paused for this session (/secret-guard on resumes)',
62  'cmd.status.disabled': 'secret-guard: disabled in settings (enabled = false)',
63  'cmd.status.total': (p: Params) => `redacted this session: ${p.n}`,
64  'cmd.status.byLabel': 'by label:',
65  'cmd.status.recent': 'most recent:',
66  'cmd.status.none': 'nothing redacted yet',
67  'cmd.status.scope': (p: Params) => `scanning: ${p.scope}`,
68  'cmd.scope.all': 'prompts, context, attachments and tool results',
69  'cmd.scope.promptsOnly': 'prompts and context only (scan_tool_results is off)',
70  'cmd.status.badPatterns': (p: Params) => `invalid custom patterns ignored: ${p.names}`,
71  'cmd.off': 'secret-guard paused: nothing is redacted until /secret-guard on.',
72  'cmd.on': 'secret-guard resumed.',
73  'cmd.test.header': 'secret-guard self-test over a built-in sample of fake values:',
74  'cmd.test.fired': (p: Params) => `  ✓ ${p.label} × ${p.n}`,
75  'cmd.test.summary': (p: Params) => `${p.n} detector(s) fired, ${p.left} placeholder(s) in the result.`,
76  'cmd.usage': 'Usage: /secret-guard (status), /secret-guard log (every hit, by fingerprint), /secret-guard clear (forget them), /secret-guard off | on (pause / resume), /secret-guard test (self-test)',
77  'ago': (p: Params) => `${p.d} ago`,
78  'cmd.status.more': (p: Params) => `  … ${p.n} more (/secret-guard log lists all)`,
79  'cmd.hit.agent': (p: Params) => `[agent ${p.id}]`,
80  'cmd.log.header': (p: Params) => `secret-guard: ${p.n} hit(s) this session, ${p.groups} distinct value(s) (fingerprints are this session's only):`,
81  'cmd.cleared': 'secret-guard: the hit list and the counters for this session are cleared.',
82} as const
83
84export type MessageKey = keyof typeof en
85export type Messages = Record<MessageKey, Message>
86
87const zhTW: Messages = {
88  'toast.redacted': p => `secret-guard:已遮蔽 ${p.n} 處(${p.labels})`,
89  'toast.redactedWhere': p => `secret-guard:已遮蔽 ${p.n} 處(${p.labels}),來源 ${p.where}`,
90  'band.summary': p => `🛡 secret-guard · 本 session 已遮蔽 ${p.n} 處 · 最近:${p.last}`,
91  'band.paused': '🛡 secret-guard · 已暫停(/secret-guard on 恢復)',
92  'band.badPatterns': p => `🛡 secret-guard · ${p.n} 條自訂規則無法編譯,已略過:${p.names}`,
93  'where.prompt': '提示',
94  'where.command': '指令',
95  'where.context': 'context',
96  'where.attachment': '附件',
97  'where.tool-result': '工具結果',
98  'where.tool-message': '工具訊息',
99  'where.delivery': '外部訊息',
100  'where.hook-context': 'hook context',
101  'where.note': '註記',
102  'where.compaction': 'compaction',
103  'where.agent': p => `${p.where},agent ${p.id}`,
104  'cmd.description': '機敏資料遮蔽狀態;log 依指紋列出每一筆;clear 清除紀錄;off / on 暫停或恢復本 session;test 用內建假資料跑一次偵測',
105  'cmd.status.enabled': 'secret-guard:啟用中',
106  'cmd.status.paused': 'secret-guard:本 session 已暫停(/secret-guard on 恢復)',
107  'cmd.status.disabled': 'secret-guard:已在設定中停用(enabled = false)',
108  'cmd.status.total': p => `本 session 已遮蔽:${p.n} 處`,
109  'cmd.status.byLabel': '依類型:',
110  'cmd.status.recent': '最近:',
111  'cmd.status.none': '還沒有遮蔽任何東西',
112  'cmd.status.scope': p => `掃描範圍:${p.scope}`,
113  'cmd.scope.all': '提示、context、附件與工具結果',
114  'cmd.scope.promptsOnly': '只掃提示與 context(scan_tool_results 已關)',
115  'cmd.status.badPatterns': p => `無法編譯的自訂規則已略過:${p.names}`,
116  'cmd.off': 'secret-guard 已暫停:到 /secret-guard on 之前不遮蔽任何東西。',
117  'cmd.on': 'secret-guard 已恢復。',
118  'cmd.test.header': 'secret-guard 自我測試(內建假資料):',
119  'cmd.test.fired': p => `  ✓ ${p.label} × ${p.n}`,
120  'cmd.test.summary': p => `${p.n} 個偵測器觸發,結果中有 ${p.left} 個 placeholder。`,
121  'cmd.usage': '用法:/secret-guard(狀態)、/secret-guard log(依指紋列出每一筆)、/secret-guard clear(清除紀錄)、/secret-guard off | on(暫停/恢復)、/secret-guard test(自我測試)',
122  'ago': p => `${p.d} 前`,
123  'cmd.status.more': p => `  … 還有 ${p.n} 筆(/secret-guard log 列出全部)`,
124  'cmd.hit.agent': p => `[agent ${p.id}]`,
125  'cmd.log.header': p => `secret-guard:本 session 共 ${p.n} 筆,${p.groups} 個不同的值(指紋只在本 session 有效):`,
126  'cmd.cleared': 'secret-guard:本 session 的紀錄與計數已清除。',
127}
128
129const ja: Messages = {
130  'toast.redacted': p => `secret-guard:${p.n} 件を伏せました(${p.labels})`,
131  'toast.redactedWhere': p => `secret-guard:${p.n} 件を伏せました(${p.labels})、${p.where}`,
132  'band.summary': p => `🛡 secret-guard · このセッションで ${p.n} 件伏せました · 直近:${p.last}`,
133  'band.paused': '🛡 secret-guard · 一時停止中(/secret-guard on で再開)',
134  'band.badPatterns': p => `🛡 secret-guard · カスタムパターン ${p.n} 件が無効のため無視:${p.names}`,
135  'where.prompt': 'プロンプト',
136  'where.command': 'コマンド',
137  'where.context': 'コンテキスト',
138  'where.attachment': '添付',
139  'where.tool-result': 'ツール結果',
140  'where.tool-message': 'ツールメッセージ',
141  'where.delivery': '外部メッセージ',
142  'where.hook-context': 'hook コンテキスト',
143  'where.note': 'ノート',
144  'where.compaction': 'compaction',
145  'where.agent': p => `${p.where}、agent ${p.id}`,
146  'cmd.description': '秘密情報の伏せ字の状態;log は指紋ごとに全件を表示;clear で記録を消去;off / on でこのセッションの一時停止・再開;test は内蔵サンプルで検出器を試す',
147  'cmd.status.enabled': 'secret-guard:有効',
148  'cmd.status.paused': 'secret-guard:このセッションでは一時停止中(/secret-guard on で再開)',
149  'cmd.status.disabled': 'secret-guard:設定で無効(enabled = false)',
150  'cmd.status.total': p => `このセッションで伏せた件数:${p.n}`,
151  'cmd.status.byLabel': '種類別:',
152  'cmd.status.recent': '直近:',
153  'cmd.status.none': 'まだ何も伏せていません',
154  'cmd.status.scope': p => `対象:${p.scope}`,
155  'cmd.scope.all': 'プロンプト、コンテキスト、添付、ツール結果',
156  'cmd.scope.promptsOnly': 'プロンプトとコンテキストのみ(scan_tool_results はオフ)',
157  'cmd.status.badPatterns': p => `無効なカスタムパターンを無視:${p.names}`,
158  'cmd.off': 'secret-guard を一時停止しました:/secret-guard on まで何も伏せません。',
159  'cmd.on': 'secret-guard を再開しました。',
160  'cmd.test.header': 'secret-guard セルフテスト(内蔵のダミー値):',
161  'cmd.test.fired': p => `  ✓ ${p.label} × ${p.n}`,
162  'cmd.test.summary': p => `${p.n} 個の検出器が反応し、結果に ${p.left} 個のプレースホルダーがあります。`,
163  'cmd.usage': '使い方:/secret-guard(状態)、/secret-guard log(指紋ごとに全件)、/secret-guard clear(記録を消去)、/secret-guard off | on(一時停止/再開)、/secret-guard test(セルフテスト)',
164  'ago': p => `${p.d}前`,
165  'cmd.status.more': p => `  … ほか ${p.n} 件(/secret-guard log で全件)`,
166  'cmd.hit.agent': p => `[agent ${p.id}]`,
167  'cmd.log.header': p => `secret-guard:このセッションで ${p.n} 件、異なる値は ${p.groups} 個(指紋はこのセッション内でのみ有効):`,
168  'cmd.cleared': 'secret-guard:このセッションの記録とカウントを消去しました。',
169}
170
171export const MESSAGES: Record<Lang, Messages> = { en: en as Messages, 'zh-TW': zhTW, ja }
172
173/** Looks a message up; a key missing in a language falls back to English. */
174export function t(lang: Lang, key: MessageKey, params: Params = {}): string {
175  const m = MESSAGES[lang][key] ?? MESSAGES.en[key]
176  return typeof m === 'function' ? m(params) : m
177}
178
hooks/logic.ts 782 lines
1// secret-guard pure logic: the detector table, `redact()`, content-block rewriting,
2// settings parsing and the texts of the band and the command.
3// No `$` here; shared with register.tsx and the tests.
4
5import type { RecentHit } from '../types'
6import type { Lang } from './i18n'
7import { t } from './i18n'
8
9export const PLUGIN = 'secret-guard'
10export const MAX_RECENT = 100
11/** How many hits `/secret-guard` (the status) lists */
12export const STATUS_RECENT = 20
13/** How long a recorded source may be */
14export const SOURCE_MAX = 120
15
16// ── Settings ───────────────────────────────────────────────────────────────
17
18export type CustomRule = { label: string; regex: RegExp }
19
20export type Settings = {
21  language: unknown
22  enabled: boolean
23  keepHint: boolean
24  entropyBackstop: boolean
25  customRules: CustomRule[]
26  /** `custom_patterns` lines that did not compile, as written */
27  badPatterns: string[]
28  allowPatterns: RegExp[]
29  scanToolResults: boolean
30  /** How the band line is framed (`band_style`) */
31  bandStyle: BandStyle
32  /** Record a per-session HMAC fingerprint of each redacted value (`fingerprints`) */
33  fingerprints: boolean
34}
35
36function bool(v: unknown, fallback: boolean): boolean {
37  if (typeof v === 'boolean') return v
38  if (v === 'true') return true
39  if (v === 'false') return false
40  return fallback
41}
42
43/** Compiles a regex source with the `g` flag added (and `i` kept if given as `/.../i`). */
44function compile(source: string): RegExp | null {
45  const text = source.trim()
46  if (!text) return null
47  const slashed = /^\/(.+)\/([a-z]*)$/.exec(text)
48  const body = slashed ? (slashed[1] as string) : text
49  const flags = new Set((slashed ? (slashed[2] as string) : '').split(''))
50  flags.add('g')
51  flags.delete('d')
52  flags.delete('y')
53  try {
54    return new RegExp(body, [...flags].join(''))
55  } catch {
56    return null
57  }
58}
59
60/** `label=regex` lines → rules; a line without `=` or with a regex that does not compile goes to `bad`. */
61export function parseCustomPatterns(text: string): { rules: CustomRule[]; bad: string[] } {
62  const rules: CustomRule[] = []
63  const bad: string[] = []
64  for (const raw of text.split('\n')) {
65    const line = raw.trim()
66    if (!line || line.startsWith('#')) continue
67    const eq = line.indexOf('=')
68    if (eq <= 0) {
69      bad.push(line)
70      continue
71    }
72    const label = line.slice(0, eq).trim().replace(/[<>]/g, '')
73    const regex = compile(line.slice(eq + 1))
74    if (!label || !regex) {
75      bad.push(line)
76      continue
77    }
78    rules.push({ label, regex })
79  }
80  return { rules, bad }
81}
82
83export function parseAllowPatterns(text: string): RegExp[] {
84  const out: RegExp[] = []
85  for (const raw of text.split('\n')) {
86    const r = compile(raw)
87    if (r) out.push(r)
88  }
89  return out
90}
91
92export function readSettings(options: Readonly<Record<string, unknown>> | undefined): Settings {
93  const o = options ?? {}
94  const custom = parseCustomPatterns(typeof o.custom_patterns === 'string' ? o.custom_patterns : '')
95  return {
96    language: o.language,
97    enabled: bool(o.enabled, true),
98    keepHint: bool(o.keep_hint, false),
99    entropyBackstop: bool(o.entropy_backstop, true),
100    customRules: custom.rules,
101    badPatterns: custom.bad,
102    allowPatterns: parseAllowPatterns(typeof o.allow_patterns === 'string' ? o.allow_patterns : ''),
103    scanToolResults: bool(o.scan_tool_results, true),
104    bandStyle: parseBandStyle(o.band_style),
105    fingerprints: bool(o.fingerprints, true),
106  }
107}
108
109// ── Detectors ──────────────────────────────────────────────────────────────
110//
111// Each detector is a global regex. The secret is the named group `secret` when
112// there is one, else the whole match; the named groups `pre` and `post` are kept
113// around the placeholder. `label` may depend on the match. `skip` vetoes a match;
114// `when` gates the whole detector on the text (e.g. a service-account JSON).
115
116export type Match = { whole: string; groups: Record<string, string | undefined>; offset: number; input: string }
117
118export type Detector = {
119  id: string
120  label: string | ((m: Match) => string)
121  regex: RegExp
122  skip?: (secret: string, m: Match) => boolean
123  when?: (text: string) => boolean
124}
125
126const SECRETISH = /(api[_-]?key|apikey|secret|token|passw(?:or)?d|pwd|credential|private[_-]?key|auth|signature|session[_-]?id|bearer|[_-]key\b|\bkey\b)/i
127
128const isServiceAccountJson = (text: string): boolean => /"type"\s*:\s*"service_account"/.test(text)
129
130/** A value that is plainly a stand-in, for detectors whose shape is already specific (headers, URLs): `<x>`, `${X}`, `$X`, xxx, ***, the same character repeated, example words. */
131export function isObviousPlaceholder(v: string): boolean {
132  const s = v.trim()
133  if (!s) return true
134  if (s.startsWith('<') || s.startsWith('${') || s.startsWith('{{') || s.startsWith('$') || s.startsWith('%')) return true
135  if (/^(x{3,}|\*{3,}|\.{3,}|_{3,}|-{3,}|#{3,})$/i.test(s)) return true
136  if (/(example|changeme|placeholder|your[_-]|dummy|sample|redacted)/i.test(s)) return true
137  if (/^(.)\1+$/.test(s)) return true
138  return false
139}
140
141/** Values that are clearly not a live secret: placeholders, env references, code, paths, URLs, flags. */
142export function isPlaceholderValue(v: string): boolean {
143  const s = v.trim()
144  if (!s) return true
145  if (s.startsWith('<') || s.startsWith('${') || s.startsWith('{{') || s.startsWith('$') || s.startsWith('%')) return true
146  if (/^(x{3,}|\*{3,}|\.{3,}|_{3,}|-{3,}|#{3,})$/i.test(s)) return true
147  if (/^(null|none|nil|undefined|true|false|empty|redacted|changeme|change-me|secret|password|token)$/i.test(s)) return true
148  if (/(example|changeme|placeholder|your[_-]|dummy|sample|redacted)/i.test(s)) return true
149  if (/^(.)\1+$/.test(s)) return true // all the same character
150  if (/[()[\]{}]/.test(s)) return true // code: a call, an index, a block
151  if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(s)) return true // dotted identifier: os.environ, process.env
152  if (/^[A-Za-z_][A-Za-z_]*$/.test(s) && s.length < 20) return true // a bare identifier without digits
153  if (/^(\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(s)) return true // a path
154  if (/:\/\//.test(s) && !/:\/\/[^/\s]+:[^@/\s]+@/.test(s)) return true // a URL without credentials
155  return false
156}
157
158const AWS_EXAMPLE_ID = 'REDACTED-AWS-ACCESS-KEY-BY-SLOPSHOPPER'
159const AWS_EXAMPLE_SECRET = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
160
161/** Documentation examples everybody pastes; never a live credential. */
162export const BUILTIN_ALLOW: readonly string[] = [AWS_EXAMPLE_ID, AWS_EXAMPLE_SECRET]
163
164export const DETECTORS: readonly Detector[] = [
165  // Google Cloud service-account key file: the two key fields, the rest of the JSON stays readable
166  {
167    id: 'gcp-sa-private-key',
168    label: 'gcp service account private key',
169    regex: /(?<pre>"private_key"\s*:\s*")(?<secret>-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----(?:\\+n)?)(?<post>")/g,
170    when: isServiceAccountJson,
171  },
172  {
173    id: 'gcp-sa-private-key-id',
174    label: 'gcp service account private key id',
175    regex: /(?<pre>"private_key_id"\s*:\s*")(?<secret>[0-9a-fA-F]{20,})(?<post>")/g,
176    when: isServiceAccountJson,
177  },
178  // Any PEM private key, real newlines or `\n`-escaped; certificates are not secrets
179  { id: 'pem-private-key', label: 'private key', regex: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----(?:\\+n)?/g },
180  // Google
181  { id: 'google-api-key', label: 'google api key', regex: /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])/g },
182  { id: 'google-oauth-access', label: 'google oauth access token', regex: /(?<![A-Za-z0-9_-])ya29\.[0-9A-Za-z_-]{20,}/g },
183  { id: 'google-oauth-refresh', label: 'google oauth refresh token', regex: /(?<![A-Za-z0-9_-])1\/\/0[0-9A-Za-z_-]{20,}/g },
184  { id: 'google-oauth-client-secret', label: 'google oauth client secret', regex: /(?<![A-Za-z0-9_-])GOCSPX-[0-9A-Za-z_-]{20,}/g },
185  // AWS
186  { id: 'aws-access-key-id', label: 'aws access key id', regex: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
187  {
188    id: 'aws-secret-access-key',
189    label: 'aws secret access key',
190    regex: /(?<pre>(?:aws_secret_access_key|secretAccessKey|aws_secret_key)[^\n]{0,40}?)(?<secret>[A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi,
191  },
192  // Model providers
193  { id: 'anthropic-api-key', label: 'anthropic api key', regex: /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{20,}/g },
194  { id: 'openai-api-key', label: 'openai api key', regex: /(?<![A-Za-z0-9_-])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g },
195  // Source forges and registries
196  { id: 'github-token', label: 'github token', regex: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g },
197  { id: 'gitlab-token', label: 'gitlab token', regex: /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{20,}/g },
198  { id: 'npm-token', label: 'npm token', regex: /\bnpm_[A-Za-z0-9]{36}\b/g },
199  { id: 'pypi-token', label: 'pypi token', regex: /(?<![A-Za-z0-9_-])pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{20,}/g },
200  { id: 'huggingface-token', label: 'hugging face token', regex: /\bhf_[A-Za-z0-9]{30,}\b/g },
201  // Chat and messaging
202  { id: 'slack-webhook', label: 'slack webhook url', regex: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Z]+\/B[0-9A-Z]+\/[0-9A-Za-z]+/g },
203  { id: 'slack-token', label: 'slack token', regex: /(?<![A-Za-z0-9_-])xox[abposre]-[0-9A-Za-z-]{10,}/g },
204  { id: 'telegram-bot-token', label: 'telegram bot token', regex: /\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b/g },
205  // Payments, mail, telephony
206  { id: 'stripe-key', label: 'stripe secret key', regex: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{24,}\b/g },
207  { id: 'sendgrid-key', label: 'sendgrid api key', regex: /(?<![A-Za-z0-9_-])SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g },
208  { id: 'twilio-key', label: 'twilio api key', regex: /\bSK[0-9a-f]{32}\b/g },
209  // Tokens with a fixed shape (JWT before Discord: both are three dot-joined parts)
210  { id: 'jwt', label: 'jwt', regex: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
211  { id: 'discord-bot-token', label: 'discord bot token', regex: /\b[MN][A-Za-z0-9_-]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g },
212  // HTTP authentication
213  {
214    id: 'http-authorization',
215    label: m => {
216      const scheme = (m.groups.scheme ?? '').toLowerCase()
217      return scheme === 'bearer' ? 'bearer token' : scheme === 'basic' ? 'basic auth' : 'token'
218    },
219    regex: /(?<pre>Authorization\s*:\s*(?<scheme>Bearer|Basic|Token)\s+)(?<secret>[^\s'"]+)/gi,
220    skip: isObviousPlaceholder,
221  },
222  {
223    id: 'api-key-header',
224    label: 'api key',
225    regex: /(?<pre>\b(?:x-api-key|api-key|apikey)\s*:\s*)(?<secret>[^\s'",;]{8,})/gi,
226    skip: isObviousPlaceholder,
227  },
228  // Credentials in a URL: scheme://user:password@host
229  {
230    id: 'url-credentials',
231    label: 'password',
232    regex: /(?<pre>[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)(?<secret>[^@\s/]+)(?<post>@)/gi,
233    skip: isObviousPlaceholder,
234  },
235  // Generic `name = value` assignments, the lowest priority: everything above has had its turn
236  {
237    id: 'generic-assignment',
238    label: 'secret value',
239    regex:
240      /(?<pre>(?:api[_-]?key|apikey|secret[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|refresh[_-]?token|private[_-]?key|password|passwd|pwd|token|secret)\b(?:\s*[:=]\s*|\s*=>\s*|"\s*:\s*")['"]?)(?<secret>[^\s'"`,;]{8,})/gi,
241    skip: isPlaceholderValue,
242  },
243]
244
245// ── Entropy backstop ───────────────────────────────────────────────────────
246
247/** Shannon entropy of the string, in bits per character. */
248export function entropy(s: string): number {
249  if (!s) return 0
250  const counts = new Map<string, number>()
251  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
252  let h = 0
253  for (const n of counts.values()) {
254    const p = n / s.length
255    h -= p * Math.log2(p)
256  }
257  return h
258}
259
260const ENTROPY_MIN = 4.0
261const ENTROPY_WINDOW = 60
262const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
263
264/**
265 * A file path rather than a token. Without this, a path written near a word such
266 * as `secret` (a folder called secret-guard) would be taken for a high-entropy
267 * secret. Two shapes: rooted (`/`, `~/`, `./`, `../`) with at least three
268 * separators; or relative with at least three separators where every segment is
269 * a lowercase word (`plugins/secret-guard/hooks/logic`), which random base64
270 * practically never is.
271 */
272export function isPathLike(v: string): boolean {
273  const slashes = (v.match(/\//g) ?? []).length
274  if (slashes < 3) return false
275  if (/^(\/|~\/|\.{1,2}\/)/.test(v)) return true
276  const segments = v.split('/').filter(Boolean)
277  return segments.length >= 3 && segments.every(seg => /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(seg))
278}
279
280/**
281 * A long random-looking token next to a secret-ish name. Conservative: hex hashes,
282 * UUIDs, base64 payloads of data URIs and tokens with no such name nearby are left alone.
283 */
284export const ENTROPY_DETECTOR: Detector = {
285  id: 'high-entropy',
286  label: 'high-entropy secret',
287  // `=` is allowed only as trailing base64 padding, so `NAME=value` never becomes one token with the name
288  regex: /(?<![A-Za-z0-9+/_<-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/=_-])/g,
289  skip: (v, m) => {
290    if (/^[0-9a-fA-F]+$/.test(v)) return true
291    if (UUID_RE.test(v)) return true
292    if (v.startsWith('<') || v.includes('…')) return true
293    if (isPathLike(v)) return true
294    if (entropy(v) < ENTROPY_MIN) return true
295    const before = m.input.slice(Math.max(0, m.offset - ENTROPY_WINDOW), m.offset)
296    if (/base64,\s*$/.test(before)) return true
297    return !SECRETISH.test(before)
298  },
299}
300
301// ── redact ─────────────────────────────────────────────────────────────────
302
303export type Hit = { label: string; count: number }
304
305/**
306 * One redacted value, as found. `value` lives only in memory, long enough for the
307 * caller to fingerprint it; it is never written to state, logs or toasts.
308 * `toolUseId` is set for matches inside a tool_result block.
309 */
310export type Found = { label: string; value: string; line: number; toolUseId?: string }
311export type Redaction = { text: string; hits: Hit[]; found: Found[] }
312
313/** 1-based line of `index` in `text`. */
314export function lineOf(text: string, index: number): number {
315  let n = 1
316  const end = Math.min(Math.max(0, index), text.length)
317  for (let i = 0; i < end; i += 1) if (text.charCodeAt(i) === 10) n += 1
318  return n
319}
320
321/**
322 * The line number a person would use for `index`: Read's own number when the line
323 * starts with one (`    12\t…` or `12→…`), else the offset-derived line.
324 */
325export function readLineNumber(text: string, index: number): number {
326  const at = Math.min(Math.max(0, index), text.length)
327  const start = text.lastIndexOf('\n', at - 1) + 1
328  const m = /^\s*(\d+)[\t→]/.exec(text.slice(start, start + 16))
329  if (m && m[1]) return Number(m[1])
330  return lineOf(text, at)
331}
332
333/** The placeholder the model reads instead of the value; with `keepHint`, the last four characters ride along. */
334export function placeholder(label: string, secret: string, keepHint: boolean): string {
335  if (!keepHint || secret.length < 8) return `<${label}>`
336  return `<${label} …${secret.slice(-4)}>`
337}
338
339function isAllowed(secret: string, settings: Settings): boolean {
340  if (BUILTIN_ALLOW.includes(secret)) return true
341  for (const r of settings.allowPatterns) {
342    r.lastIndex = 0
343    if (r.test(secret)) return true
344  }
345  return false
346}
347
348/** `String.replace` hands the callback (match, ...captures, offset, input, groups?); this picks them apart. */
349function readArgs(args: unknown[]): Match {
350  const whole = String(args[0])
351  const last = args[args.length - 1]
352  const hasGroups = last !== null && typeof last === 'object'
353  const groups = (hasGroups ? last : {}) as Record<string, string | undefined>
354  const input = String(args[hasGroups ? args.length - 2 : args.length - 1])
355  const offset = Number(args[hasGroups ? args.length - 3 : args.length - 2])
356  return { whole, groups, offset, input }
357}
358
359function applyDetector(text: string, d: Detector, settings: Settings, counts: Map<string, number>, found: Found[]): string {
360  if (d.when && !d.when(text)) return text
361  d.regex.lastIndex = 0
362  return text.replace(d.regex, (...args: unknown[]) => {
363    const m = readArgs(args)
364    const secret = m.groups.secret ?? m.whole
365    if (!secret) return m.whole
366    if (isAllowed(secret, settings)) return m.whole
367    if (d.skip && d.skip(secret, m)) return m.whole
368    const label = typeof d.label === 'function' ? d.label(m) : d.label
369    counts.set(label, (counts.get(label) ?? 0) + 1)
370    found.push({ label, value: secret, line: readLineNumber(m.input, m.offset + (m.groups.pre ?? '').length) })
371    return `${m.groups.pre ?? ''}${placeholder(label, secret, settings.keepHint)}${m.groups.post ?? ''}`
372  })
373}
374
375/** Replaces every secret the detectors find; idempotent (placeholders never match). */
376export function redact(text: string, settings: Settings): Redaction {
377  if (!text) return { text, hits: [], found: [] }
378  const counts = new Map<string, number>()
379  const found: Found[] = []
380  let out = text
381  for (const d of DETECTORS) out = applyDetector(out, d, settings, counts, found)
382  for (const r of settings.customRules) out = applyDetector(out, { id: `custom:${r.label}`, label: r.label, regex: r.regex }, settings, counts, found)
383  if (settings.entropyBackstop) out = applyDetector(out, ENTROPY_DETECTOR, settings, counts, found)
384  const hits = [...counts.entries()].map(([label, count]) => ({ label, count }))
385  return { text: out, hits, found }
386}
387
388/** Sums hit lists. */
389export function mergeHits(lists: readonly Hit[][]): Hit[] {
390  const counts = new Map<string, number>()
391  for (const list of lists) for (const h of list) counts.set(h.label, (counts.get(h.label) ?? 0) + h.count)
392  return [...counts.entries()].map(([label, count]) => ({ label, count }))
393}
394
395export function hitTotal(hits: readonly Hit[]): number {
396  return hits.reduce((n, h) => n + h.count, 0)
397}
398
399export function hitLabels(hits: readonly Hit[]): string {
400  return hits.map(h => (h.count > 1 ? `${h.label} ×${h.count}` : h.label)).join(', ')
401}
402
403// ── Content blocks ─────────────────────────────────────────────────────────
404
405export type Block = { type: string; [field: string]: unknown }
406
407/**
408 * Rewrites the text of `text` blocks and of `tool_result` blocks (a string, or an
409 * array of text blocks). Every other block is returned as is. `changed` is false
410 * when nothing was redacted, so the caller can pass the row through untouched.
411 */
412export function redactBlocks(
413  blocks: readonly Block[],
414  settings: Settings,
415  toolUseId?: string,
416): { blocks: Block[]; hits: Hit[]; found: Found[]; changed: boolean } {
417  const all: Hit[][] = []
418  const found: Found[] = []
419  let changed = false
420  const tag = (list: Found[], id: string | undefined) => (id === undefined ? list : list.map(f => ({ ...f, toolUseId: id })))
421  const out = blocks.map(b => {
422    if (b.type === 'text' && typeof b.text === 'string') {
423      const r = redact(b.text, settings)
424      if (!r.hits.length) return b
425      changed = true
426      all.push(r.hits)
427      found.push(...tag(r.found, toolUseId))
428      return { ...b, text: r.text }
429    }
430    if (b.type === 'tool_result') {
431      const id = typeof b.tool_use_id === 'string' ? b.tool_use_id : undefined
432      if (typeof b.content === 'string') {
433        const r = redact(b.content, settings)
434        if (!r.hits.length) return b
435        changed = true
436        all.push(r.hits)
437        found.push(...tag(r.found, id))
438        return { ...b, content: r.text }
439      }
440      if (Array.isArray(b.content)) {
441        const inner = redactBlocks(b.content as Block[], settings, id)
442        if (!inner.changed) return b
443        changed = true
444        all.push(inner.hits)
445        found.push(...inner.found)
446        return { ...b, content: inner.blocks }
447      }
448    }
449    return b
450  })
451  return { blocks: changed ? out : [...blocks], hits: mergeHits(all), found, changed }
452}
453
454// ── Doors ──────────────────────────────────────────────────────────────────
455
456/** The rows the model reads that a person, a tool or the engine wrote; never the model's own words or a notice. */
457export const SCAN_DOORS = ['prompt', 'command', 'tool-result', 'tool-message', 'delivery', 'attachment', 'hook-context', 'note', 'compaction'] as const
458/** With `scan_tool_results` off, only what the person typed or ran. */
459export const PROMPT_DOORS = ['prompt', 'command'] as const
460
461export function isScannedDoor(door: string, settings: Settings): boolean {
462  const list: readonly string[] = settings.scanToolResults ? SCAN_DOORS : PROMPT_DOORS
463  return list.includes(door)
464}
465
466// ── The session.append decision ────────────────────────────────────────────
467
468export type AppendRow = { message: { content?: unknown; [k: string]: unknown }; door: string; agentId?: string; [k: string]: unknown }
469export type AppendPlan = { kind: 'pass' } | { kind: 'rewrite'; input: AppendRow; hits: Hit[]; found: Found[]; where: string; agent: string | null }
470
471/**
472 * What the session.append hook should do with a row: pass it through (a door the
473 * model never reads, nothing to redact, no content array) or rewrite its blocks.
474 * Pure, so the tests can drive it; the hook itself only adds the `$` calls.
475 */
476export function planAppend(e: AppendRow, settings: Settings): AppendPlan {
477  if (!isScannedDoor(String(e.door), settings)) return { kind: 'pass' }
478  const content = e.message?.content
479  if (!Array.isArray(content)) return { kind: 'pass' }
480  const r = redactBlocks(content as Block[], settings)
481  if (!r.changed) return { kind: 'pass' }
482  return {
483    kind: 'rewrite',
484    input: { ...e, message: { ...e.message, content: r.blocks } },
485    hits: r.hits,
486    found: r.found,
487    where: String(e.door),
488    agent: typeof e.agentId === 'string' ? e.agentId : null,
489  }
490}
491
492// ── Text ───────────────────────────────────────────────────────────────────
493
494export function duration(ms: number): string {
495  const s = Math.max(0, Math.floor(ms / 1000))
496  if (s < 60) return `${s}s`
497  if (s < 3600) return `${Math.floor(s / 60)}m`
498  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
499}
500
501const WHERE_KEYS = ['prompt', 'command', 'context', 'attachment', 'tool-result', 'tool-message', 'delivery', 'hook-context', 'note', 'compaction'] as const
502type WhereKey = (typeof WHERE_KEYS)[number]
503
504export function whereText(lang: Lang, where: string, agent: string | null): string {
505  const key = (WHERE_KEYS as readonly string[]).includes(where) ? (`where.${where as WhereKey}` as const) : null
506  const base = key ? t(lang, key) : where
507  return agent ? t(lang, 'where.agent', { where: base, id: agent.slice(0, 8) }) : base
508}
509
510export function toastText(lang: Lang, hits: readonly Hit[], where: string, agent: string | null, from?: { tool?: string; source?: string }): string {
511  const n = hitTotal(hits)
512  const labels = hitLabels(hits)
513  const base = where === 'prompt' && !agent ? t(lang, 'toast.redacted', { n, labels }) : t(lang, 'toast.redactedWhere', { n, labels, where: whereText(lang, where, agent) })
514  const short = from?.tool ? shortSource(from.tool, from.source) : ''
515  return short ? `${base} · ${short}` : base
516}
517
518// ── Where a hit came from ──────────────────────────────────────────────────
519
520/**
521 * What a tool call is about, for the hit record: a file path for tools that name
522 * one, else a Bash command or a Grep pattern. Never the tool's output. The result
523 * is redacted itself (a command line may carry a token) and cut to SOURCE_MAX.
524 */
525export function toolSource(tool: string, input: Readonly<Record<string, unknown>>, settings: Settings): string | undefined {
526  const pick = (k: string) => (typeof input[k] === 'string' && (input[k] as string).trim() ? (input[k] as string) : undefined)
527  const raw = pick('file_path') ?? pick('path') ?? pick('notebook_path') ?? (tool === 'Bash' ? pick('command') : undefined) ?? pick('pattern') ?? pick('url') ?? pick('query')
528  if (raw === undefined) return undefined
529  return clipMiddle(redact(raw.replace(/\s+/g, ' ').trim(), settings).text, SOURCE_MAX)
530}
531
532/**
533 * Cuts the middle out of a long source so both ends survive: the start of a command
534 * and the file name at the end of a path (`/private/tmp/…/scratchpad/fp-test.env`).
535 */
536export function clipMiddle(s: string, max: number): string {
537  if (s.length <= max) return s
538  const head = Math.floor((max - 1) * 0.35)
539  const tail = max - 1 - head
540  return `${s.slice(0, head)}…${s.slice(s.length - tail)}`
541}
542
543export function clip(s: string, max: number): string {
544  return s.length <= max ? s : `${s.slice(0, max - 1)}…`
545}
546
547/** `~` for the home directory, so the list stays short. */
548export function abbreviateHome(path: string, home: string | null | undefined): string {
549  if (!home) return path
550  const h = home.replace(/\/+$/, '')
551  if (path === h) return '~'
552  return path.startsWith(`${h}/`) ? `~${path.slice(h.length)}` : path
553}
554
555/** The toast's tail: the tool and the file's base name, or the command cut short. */
556export function shortSource(tool: string | undefined, source: string | undefined): string {
557  if (!source) return ''
558  const isPath = /^(\/|~\/|\.{1,2}\/)/.test(source) || /^[^\s]+\.[A-Za-z0-9]+$/.test(source)
559  const what = isPath ? source.slice(source.lastIndexOf('/') + 1) : clip(source, 30)
560  return tool ? `${tool} ${what}` : what
561}
562
563/** HH:MM in the machine's local time. */
564export function clockText(at: number): string {
565  const d = new Date(at)
566  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
567}
568
569/** Where one hit happened, as one phrase: `Read ~/proj/.env:4`, `Bash cat .env`, `prompt`. */
570export function locationText(lang: Lang, h: RecentHit, home: string | null | undefined): string {
571  const parts: string[] = []
572  if (h.tool) parts.push(h.tool)
573  if (h.source) parts.push(`${abbreviateHome(h.source, home)}${h.line !== undefined && h.tool ? `:${h.line}` : ''}`)
574  if (!h.tool && h.source === h.where) parts.length = 0
575  if (!parts.length) parts.push(whereText(lang, h.where, null))
576  else if (!h.tool) parts.unshift(`${whereText(lang, h.where, null)}:`)
577  if (h.agent) parts.push(t(lang, 'cmd.hit.agent', { id: h.agent.slice(0, 8) }))
578  return parts.join(' ')
579}
580
581/** One line of the status list: `14:02  aws secret access key  #a3f91c02  Read ~/proj/.env:4`. */
582export function hitLine(lang: Lang, h: RecentHit, home: string | null | undefined): string {
583  return [clockText(h.at), h.label, h.fingerprint, locationText(lang, h, home)].filter(Boolean).join('  ')
584}
585
586/**
587 * `/secret-guard log`: every recorded hit, grouped by fingerprint (by label when
588 * fingerprints are off), the busiest group first, each location indented.
589 */
590export function logText(lang: Lang, recent: readonly RecentHit[], home: string | null | undefined): string {
591  if (!recent.length) return t(lang, 'cmd.status.none')
592  const groups = new Map<string, { fingerprint?: string; label: string; hits: RecentHit[] }>()
593  for (const h of recent) {
594    const key = h.fingerprint ? `${h.fingerprint} ${h.label}` : `label:${h.label}`
595    const g = groups.get(key) ?? { fingerprint: h.fingerprint, label: h.label, hits: [] }
596    g.hits.push(h)
597    groups.set(key, g)
598  }
599  const lines = [t(lang, 'cmd.log.header', { n: recent.length, groups: groups.size })]
600  for (const g of [...groups.values()].sort((a, b) => b.hits.length - a.hits.length)) {
601    lines.push([g.fingerprint, g.label, `×${g.hits.length}`].filter(Boolean).join('  '))
602    for (const h of g.hits) lines.push(`    ${clockText(h.at)}  ${locationText(lang, h, home)}`)
603  }
604  return lines.join('\n')
605}
606
607// ── Fingerprints ───────────────────────────────────────────────────────────
608
609export function toHex(bytes: Uint8Array): string {
610  let out = ''
611  for (const b of bytes) out += b.toString(16).padStart(2, '0')
612  return out
613}
614
615export function fromHex(hex: string): Uint8Array {
616  const out = new Uint8Array(Math.floor(hex.length / 2))
617  for (let i = 0; i < out.length; i += 1) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
618  return out
619}
620
621/** A fresh 32-byte fingerprint key, hex. */
622export function newFingerprintKey(): string {
623  return toHex(crypto.getRandomValues(new Uint8Array(32)))
624}
625
626async function sha256(data: Uint8Array): Promise<Uint8Array> {
627  return new Uint8Array(await crypto.subtle.digest('SHA-256', data))
628}
629
630function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
631  const out = new Uint8Array(a.length + b.length)
632  out.set(a, 0)
633  out.set(b, a.length)
634  return out
635}
636
637/**
638 * HMAC-SHA256 (RFC 2104) over `crypto.subtle.digest`, the one Web Crypto call the
639 * hooks environment declares: H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m)), block size 64.
640 */
641export async function hmacSha256(key: Uint8Array, message: Uint8Array): Promise<Uint8Array> {
642  const block = new Uint8Array(64)
643  block.set(key.length > 64 ? await sha256(key) : key)
644  const ipad = block.map(b => b ^ 0x36)
645  const opad = block.map(b => b ^ 0x5c)
646  return sha256(concat(opad, await sha256(concat(ipad, message))))
647}
648
649/** `#` + the first 8 hex digits of HMAC-SHA256(key, value). */
650export async function fingerprint(keyHex: string, value: string): Promise<string> {
651  const mac = await hmacSha256(fromHex(keyHex), new TextEncoder().encode(value))
652  return `#${toHex(mac).slice(0, 8)}`
653}
654
655export type BandState = { isPaused: boolean; total: number; recent: readonly RecentHit[]; badPatterns: readonly string[]; enabled: boolean }
656
657/** The band line; null when there is nothing to say (enabled, nothing redacted, no bad pattern). */
658export function bandText(lang: Lang, s: BandState): string | null {
659  if (!s.enabled) return null
660  if (s.isPaused) return t(lang, 'band.paused')
661  if (s.badPatterns.length) return t(lang, 'band.badPatterns', { n: s.badPatterns.length, names: s.badPatterns.map(p => p.split('=')[0]).join(', ') })
662  if (s.total === 0) return null
663  const last = s.recent[s.recent.length - 1]
664  return t(lang, 'band.summary', { n: s.total, last: last ? lastHitText(lang, last) : '' })
665}
666
667/**
668 * The band's "last" part, one short phrase: label, fingerprint, the tool and the file's
669 * base name with its line (never the full path, which stays in /secret-guard), and the time.
670 * `github token #ea21bbd9 · Read fp-test.env:2 · 23:11`
671 */
672export function lastHitText(lang: Lang, h: RecentHit): string {
673  const head = h.fingerprint ? `${h.label} ${h.fingerprint}` : h.label
674  let where: string
675  if (h.tool && h.source) {
676    const short = shortSource(h.tool, h.source)
677    const isPath = !short.startsWith(`${h.tool} `) || !/\s/.test(short.slice(h.tool.length + 1))
678    where = isPath && h.line !== undefined ? `${short}:${h.line}` : short
679    if (h.agent) where += ` ${t(lang, 'cmd.hit.agent', { id: h.agent.slice(0, 8) })}`
680  } else {
681    where = whereText(lang, h.where, h.agent)
682  }
683  return [head, where, clockText(h.at)].join(' · ')
684}
685
686export type StatusState = BandState & { byLabel: Readonly<Record<string, number>>; scanToolResults: boolean; now: number; home?: string | null }
687
688export function statusText(lang: Lang, s: StatusState): string {
689  const lines: string[] = []
690  lines.push(!s.enabled ? t(lang, 'cmd.status.disabled') : s.isPaused ? t(lang, 'cmd.status.paused') : t(lang, 'cmd.status.enabled'))
691  lines.push(t(lang, 'cmd.status.scope', { scope: t(lang, s.scanToolResults ? 'cmd.scope.all' : 'cmd.scope.promptsOnly') }))
692  if (s.badPatterns.length) lines.push(t(lang, 'cmd.status.badPatterns', { names: s.badPatterns.join(' | ') }))
693  if (s.total === 0) {
694    lines.push(t(lang, 'cmd.status.none'))
695    return lines.join('\n')
696  }
697  lines.push(t(lang, 'cmd.status.total', { n: s.total }))
698  lines.push(t(lang, 'cmd.status.byLabel'))
699  for (const [label, n] of Object.entries(s.byLabel).sort((a, b) => b[1] - a[1])) lines.push(`  ${label}: ${n}`)
700  lines.push(t(lang, 'cmd.status.recent'))
701  for (const h of [...s.recent].slice(-STATUS_RECENT).reverse()) lines.push(`  ${hitLine(lang, h, s.home)}`)
702  if (s.recent.length > STATUS_RECENT) lines.push(t(lang, 'cmd.status.more', { n: s.recent.length - STATUS_RECENT }))
703  return lines.join('\n')
704}
705
706/** A sample of obviously fake values, one per detector family, for `/secret-guard test`. Built from pieces so no line looks like a credential. */
707export function selfTestSample(): string {
708  const rep = (ch: string, n: number) => ch.repeat(n)
709  const lines = [
710    `GOOGLE_API_KEY=AIza${rep('A', 35)}`,
711    `access_token=ya29.${rep('a', 24)}`,
712    `AWS_ACCESS_KEY_ID=AKIA${rep('Q', 16)}`,
713    `aws_secret_access_key = ${rep('b', 40)}`,
714    `ANTHROPIC_API_KEY=sk-ant-${rep('c', 24)}`,
715    `OPENAI_API_KEY=sk-${rep('d', 24)}`,
716    `GITHUB_TOKEN=ghp_${rep('e', 36)}`,
717    `SLACK_TOKEN=xoxb-${rep('1', 12)}`,
718    `Authorization: Bearer ${rep('f', 18)}42`,
719    `postgres://app:${rep('g', 10)}42@db.internal/app`,
720    `password = ${rep('h', 10)}1`,
721    `REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER`,
722  ]
723  return lines.join('\n')
724}
725
726export function selfTestText(lang: Lang, settings: Settings): string {
727  const r = redact(selfTestSample(), settings)
728  const lines = [t(lang, 'cmd.test.header')]
729  for (const h of r.hits) lines.push(t(lang, 'cmd.test.fired', { label: h.label, n: h.count }))
730  const left = (r.text.match(/<[a-z][a-z -]*(?: …[^>]{4})?>/g) ?? []).length
731  lines.push(t(lang, 'cmd.test.summary', { n: r.hits.length, left }))
732  return lines.join('\n')
733}
734
735/** Appends hit records to the session's recent list, newest last, keeping at most MAX_RECENT. */
736export function pushRecent(recent: readonly RecentHit[], items: readonly RecentHit[]): RecentHit[] {
737  return [...recent, ...items].slice(-MAX_RECENT)
738}
739
740export type ToolInfo = { tool: string; source?: string }
741
742/**
743 * The hit records for one redaction, values dropped: each found value with its
744 * tool and source (looked up by tool_use_id, else the default source), its line
745 * and its fingerprint (computed by the caller; undefined when off).
746 */
747export function hitRecords(
748  found: readonly Found[],
749  fingerprints: readonly (string | undefined)[],
750  ctx: { where: string; agent: string | null; at: number; source?: string; lookup: (toolUseId: string) => ToolInfo | undefined },
751): RecentHit[] {
752  return found.map((f, i) => {
753    const info = f.toolUseId !== undefined ? ctx.lookup(f.toolUseId) : undefined
754    const rec: RecentHit = { label: f.label, where: ctx.where, agent: ctx.agent, at: ctx.at }
755    if (info) {
756      rec.tool = info.tool
757      if (info.source) rec.source = info.source
758    } else if (ctx.source) rec.source = ctx.source
759    rec.line = f.line
760    const fp = fingerprints[i]
761    if (fp) rec.fingerprint = fp
762    return rec
763  })
764}
765
766export function addByLabel(byLabel: Readonly<Record<string, number>>, hits: readonly Hit[]): Record<string, number> {
767  const out = { ...byLabel }
768  for (const h of hits) out[h.label] = (out[h.label] ?? 0) + h.count
769  return out
770}
771
772
773// ── Band framing ───────────────────────────────────────────────────────────
774
775/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
776export type BandStyle = 'box' | 'rule' | 'plain'
777
778/** The `band_style` option; anything but `rule` or `plain` is the default box. */
779export function parseBandStyle(v: unknown): BandStyle {
780  return v === 'rule' || v === 'plain' ? v : 'box'
781}
782
types/index.d.ts 56 lines
1// secret-guard: data types and the $.state contract.
2
3export type GuardLang = 'en' | 'zh-TW' | 'ja'
4
5/**
6 * One redaction the session saw: the label, where it happened and a fingerprint,
7 * never the value. Kept for this session only.
8 */
9export type RecentHit = {
10  /** The placeholder label, e.g. `google api key` */
11  label: string
12  /** Which door the text came in by: `prompt`, `context`, `attachment`, `tool-result`, ... */
13  where: string
14  /** The subagent whose conversation carried it; null on the main conversation */
15  agent: string | null
16  /** When, in `$.clock.now()` milliseconds */
17  at: number
18  /** The tool whose result carried it (`Read`, `Bash`, `Grep`, ...); absent for prompts, context and attachments */
19  tool?: string
20  /**
21   * Where the text came from: a file path for tools that name one, else the Bash
22   * command or the Grep pattern (redacted itself, at most 120 characters); `prompt`,
23   * a context block's name or an attachment's type otherwise. Never the tool's output.
24   */
25  source?: string
26  /** 1-based line of the match in the scanned text (Read's own line numbers when present) */
27  line?: number
28  /**
29   * `#` + the first 8 hex digits of HMAC-SHA256 over the value, keyed with a random
30   * key made for this session: the same value gets the same fingerprint within the
31   * session, and nothing outside the session can compare or reverse it. Absent when
32   * the `fingerprints` setting is off.
33   */
34  fingerprint?: string
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'secret-guard': {
40      lang: GuardLang
41      /** `/secret-guard off` for this session */
42      isPaused: boolean
43      /** Redactions this session, in all */
44      total: number
45      /** Redactions this session, by label */
46      byLabel: Record<string, number>
47      /** The newest redactions (at most 100), newest last */
48      recent: RecentHit[]
49      /** This session's fingerprint key, hex; kept here so a hot reload keeps fingerprints stable */
50      fpKey: string
51      /** `custom_patterns` lines that did not compile, reported once in the band */
52      badPatterns: string[]
53    }
54  }
55}
56