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…

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
| Hook | What passes through it |
|---|---|
session.append | Every 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.submit | Your message, so it is shown and stored redacted. |
prompt.context | The context blocks the first message carries (CLAUDE.md and friends). |
prompt.attachment | Texts 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.
| Placeholder | Matches | ||
|---|---|---|---|
<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: Bearer | Basic | Token` |
<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.
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).
| Command | Does |
|---|---|
/secret-guard | status: enabled / paused, what is scanned, totals by label, and the last 20 hits, one per line (see below) |
/secret-guard log | every hit recorded this session (up to 100), grouped by fingerprint: the same secret seen in several places is one group |
/secret-guard clear | forgets the hit list and the counters of this session |
/secret-guard off / on | pause / resume for this session (the enabled setting is the permanent switch) |
/secret-guard test | runs every detector over a built-in sample of obviously fake values and prints which labels fired; a self-test, no real secrets involved |
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
| Field | What it is |
|---|---|
| time | when the row passed |
| label | the 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 source | for 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 |
| line | the 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) |
| agent | the 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.
| Setting | Default | Meaning |
|---|---|---|
language | auto | UI language: auto (from LC_ALL / LC_MESSAGES / LANG), en, zh-TW, ja |
enabled | true | Off: nothing is scanned or rewritten |
keep_hint | false | Keep the last 4 characters in the placeholder |
entropy_backstop | true | The high-entropy detector |
custom_patterns | empty | Extra 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_patterns | empty | One 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_results | true | Off: only your prompts, slash-command rows and the context blocks are scanned; tool results, attachments, deliveries, notes and compaction summaries pass through |
fingerprints | true | Record a per-session HMAC fingerprint of each redacted value (see "The hit record"); off records the hit without one |
band_style | box | How 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.
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.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.
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.
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.
/secret-guard clear empties it.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.tool_result blocks are rewritten. Thinking, tool_use, image and document blocks are pinned by the engine or carry no text.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.
hooks/register.tsx 317 lines1// 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}
317hooks/i18n.ts 178 lines1// 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}
178hooks/logic.ts 782 lines1// 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}
782types/index.d.ts 56 lines1// 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