Keeps secrets (API keys, tokens, passwords, private keys) out of the model's context: hides them in tool output and asks before a typed prompt that contains…

Install: /plugin install secret-guard --marketplace samuelbfernandes/moddeck
Keeps API keys, tokens, passwords and private keys out of what the model reads.
[secret:github-token] before the model sees it. A second hook redacts every tool result row (including errors) as the model reads it. Once per turn a dim line says Secret guard · hid 2 secrets from tool output.secret-guard blocked your prompt · nothing was sent, and your text is shown again with the secrets hidden. Only your own typing is checked, never notifications or scheduled runs.Authorization: headers, user:password@ in URLs, and password / token / secret / api_key assignments in .env, YAML, JSON, Python and R (values of 8+ characters that are not placeholders or code). Git SHAs, hashes, base64 data and password <- NULL are left alone. Counts and kinds are kept, never a secret./secret-guard what was hidden this session, by kind /secret-guard off pause it (remembered); /secret-guard on resumes
The / is required: a bare secret-guard goes to the model like any prompt.
/secret-guard off, or the enabled setting, or /plugin disable secret-guard.
enabled, askOnPrompt (default on; off = typed prompts are not checked), redactEmails (default off; IP addresses are never hidden), extraPatterns (JavaScript regexes separated by ;;, shown as [secret:custom]; a bad one is reported).
^ * ? $ ( )) is not found. Keep .env files out of git and out of your prompts anyway.[secret:...] instead of the value, so if it rewrites a file it read, the placeholder is written.@file mentions, pasted images, the context notes beside a result, tools outside the list above (their result row is redacted, their stored record is not), and the screen, which may show output before it is redacted.hooks/register.tsx 167 lines1// secret-guard: keeps secrets out of the model's context.
2//
3// 1. Tool output. `tool.call` rewrites the result of Read, Bash, Grep, WebFetch and MCP tools before the model
4// reads it; a `session.append` hook on the `tool-result` door is the net under it: it redacts the row the model
5// actually reads, for EVERY tool and for errored results (which a `tool.call` answer cannot rewrite).
6// 2. Typed prompts. A prompt the person types that holds a secret-looking string is held for one question.
7// 3. `/secret-guard` reports counts by kind; `/secret-guard off|on` switches it.
8//
9// A secret is NEVER logged, stored or counted by value: tallies are kinds and numbers, the question names kinds,
10// and the echo of a held prompt is the redacted text.
11//
12// FAIL OPEN: this is a convenience, not a gate. A hook that throws leaves the tool result, the row or the prompt
13// exactly as it was (`.catch` calls `next(e)`; where `next` had already run it replays the same answer). The only
14// refusal is deliberate: the person answering "Hold it", pressing Esc or typing something else at the question.
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import { readConfig } from './config'
19import type { Config } from './config'
20import { countOf, redact } from './detect'
21import type { Hits } from './detect'
22import { dropWith } from './reply'
23import { ASK_HEADER, EMPTY_TALLY, HOLD, HOLD_REASON, SEND_AS_TYPED, SEND_REDACTED, announce, askText, report } from './report'
24import { scrubBlocks, scrubValue } from './scrub'
25import { isPeople } from './words'
26
27const COMMAND = 'secret-guard'
28const STORE_KEY = 'enabled'
29const ECHO_LIMIT = 2000
30/** Built-in tools whose result is text the model reads; MCP tools (`mcp__*`) are added by prefix. */
31const WATCHED = new Set(['Read', 'Bash', 'Grep', 'WebFetch', 'ReadMcpResourceTool', 'GetTask'])
32
33const tally = atom({ plugin: 'secret-guard', key: 'tally' } as const, EMPTY_TALLY)
34const isOnAtom = atom({ plugin: 'secret-guard', key: 'isOn' } as const, null)
35
36const isWatched = (tool: string): boolean => WATCHED.has(tool) || tool.startsWith('mcp__')
37
38/** Whether the guard is on: the settings switch AND the person's own word (kept in $.store, read once). */
39async function isOn($: EngineInterface, cfg: Config): Promise<boolean> {
40 if (!cfg.enabled) return false
41 const live = await read($, isOnAtom)
42 if (live !== null) return live
43 const saved = (await safe(() => $.store.get(STORE_KEY))) !== false
44 await safe(() => update($, isOnAtom, () => saved))
45 return saved
46}
47
48/** Counts what was hidden. Never throws: bookkeeping must not undo a redaction already made. */
49async function note($: EngineInterface, source: 'tool' | 'typed', hits: Hits): Promise<void> {
50 let isFirst = false
51 try {
52 await update($, tally, t => {
53 isFirst = source === 'tool' && !t.hasAnnounced
54 const bucket: Hits = { ...t[source] }
55 for (const [kind, n] of Object.entries(hits)) bucket[kind] = (bucket[kind] ?? 0) + n
56 return { ...t, [source]: bucket, hasAnnounced: t.hasAnnounced || source === 'tool' }
57 })
58 if (isFirst) $.ui.log(announce(hits))
59 } catch {
60 // the redaction already happened; losing a count is the lesser harm
61 }
62}
63
64/** What `/secret-guard [on|off]` does; the reply is plain text. */
65async function run($: EngineInterface, cfg: Config, arg: string): Promise<string> {
66 const wanted = arg.trim().toLowerCase()
67 if (wanted !== '' && wanted !== 'on' && wanted !== 'off') return `Secret guard · unknown option “${wanted.slice(0, 30)}”\nUse /secret-guard, /secret-guard on or /secret-guard off.`
68 if (wanted !== '') {
69 await $.store.set(STORE_KEY, wanted === 'on')
70 await update($, isOnAtom, () => wanted === 'on')
71 }
72 return report({ tally: await read($, tally), isOn: await isOn($, cfg), offBySetting: !cfg.enabled, problems: cfg.problems })
73}
74
75export const register: Register = (on, options) => {
76 const cfg = readConfig(options)
77
78 on('session.start', async ($, e, next) => {
79 // Fail-open: a store that cannot be read leaves the guard on.
80 await isOn($, cfg)
81 await $.command.register({ name: COMMAND, description: 'Show what secret-guard hid this session; on / off switches it', argumentHint: '[on|off]' })
82 for (const problem of cfg.problems) safeLog($, `Secret guard · ${problem}`)
83 return next(e)
84 })
85
86 // Answers its own command and reads no `next`: a failure only says so.
87 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, cfg, e.args) })).catch(() => ({ text: 'Secret guard · could not read its own state' }))
88
89 // 1a. Tool results, as a record. Only an answered result is rewritten; an `{ result }` of our own carries no `ref` or
90 // `text`, so core maps it for the model again with the tool's own mapper. The errored ones are the net's job below.
91 // The `context` that rides beside a result is passed on as it came: the engine refuses a hook that leaves an entry out.
92 on('tool.call', async ($, e, next) => {
93 const ran = await next(e)
94 if (ran.deny !== undefined || ran.isError === true || !isWatched(e.tool)) return ran
95 if (!(await isOn($, cfg))) return ran
96
97 const hits: Hits = {}
98 const result = scrubValue(ran.result, cfg.rules, hits)
99 if (countOf(hits) === 0) return ran
100 await note($, 'tool', hits)
101
102 return (ran.context === undefined ? { result } : { result, context: ran.context }) as typeof ran
103 }).catch(($, e, next) => next(e))
104
105 // 1b. The net: the row the model reads, for every tool and for errors. Idempotent with 1a (placeholders match nothing).
106 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
107 if (!(await isOn($, cfg))) return next(e)
108 const hits: Hits = {}
109 const content = scrubBlocks(e.message.content, cfg.rules, hits)
110 if (countOf(hits) === 0) return next(e)
111 await note($, 'tool', hits)
112
113 return next({ ...e, message: { ...e.message, content: [...content] } })
114 }).catch(($, e, next) => next(e))
115
116 // 2. The check of what a person typed.
117 on('prompt.submit', async ($, e, next) => {
118 await safe(() => update($, tally, t => (t.hasAnnounced ? { ...t, hasAnnounced: false } : t))) // a new turn may speak once
119 if (!cfg.askOnPrompt || !isPeople(e.origin) || !(await isOn($, cfg))) return next(e)
120
121 const { text: redacted, hits } = redact(e.text, cfg.rules)
122 if (countOf(hits) === 0) return next(e)
123
124 const choice = await choose($, hits)
125 if (choice === 'redacted') {
126 await note($, 'typed', hits)
127 return next({ ...e, text: redacted })
128 }
129 if (choice === 'typed') {
130 await safe(() => update($, tally, t => ({ ...t, sentAsTyped: t.sentAsTyped + 1 })))
131 return next(e)
132 }
133 await safe(() => update($, tally, t => ({ ...t, held: t.held + 1 })))
134 // The held text is echoed with its secrets hidden: the person gets their words back, the transcript gets no secret.
135 safeLog($, `Held text, secrets hidden:\n${redacted.length > ECHO_LIMIT ? `${redacted.slice(0, ECHO_LIMIT)}…` : redacted}`)
136 return dropWith({ log: line => $.ui.log(line) }, HOLD_REASON)
137 }).catch(($, e, next) => next(e))
138}
139
140/** The person's answer. Esc, a dismissed dialog and free text under "Other" all hold: nothing was said yes to. */
141async function choose($: EngineInterface, hits: Hits): Promise<'redacted' | 'typed' | 'hold'> {
142 try {
143 const answer = await $.ui.ask(askText(hits), { options: [SEND_REDACTED, SEND_AS_TYPED, HOLD], header: ASK_HEADER })
144 if (answer === SEND_REDACTED) return 'redacted'
145 if (answer === SEND_AS_TYPED) return 'typed'
146 } catch {
147 // dismissed
148 }
149 return 'hold'
150}
151
152function safeLog($: EngineInterface, text: string): void {
153 try {
154 $.ui.log(text)
155 } catch {
156 // a note is a courtesy
157 }
158}
159
160async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
161 try {
162 return await f()
163 } catch {
164 return undefined
165 }
166}
167hooks/config.ts 25 lines1import type { PluginOptions } from 'claude-code'
2
3import { parseExtra, rulesFor } from './detect'
4import type { Rule } from './detect'
5
6export type Config = {
7 /** The settings switch; the person's on/off word is kept separately in $.store. */
8 enabled: boolean
9 askOnPrompt: boolean
10 rules: Rule[]
11 /** What was wrong with `extraPatterns`, one line each; empty when all is well. */
12 problems: string[]
13}
14
15/** Options from plugin.json userConfig; anything unusable falls back to the default. */
16export function readConfig(options: PluginOptions): Config {
17 const extra = parseExtra(options.extraPatterns)
18 return {
19 enabled: options.enabled !== false,
20 askOnPrompt: options.askOnPrompt !== false,
21 rules: rulesFor({ redactEmails: options.redactEmails === true, extra: extra.rules }),
22 problems: extra.problems,
23 }
24}
25hooks/detect.ts 249 lines1// The patterns and the redaction itself: plain functions over strings, no engine, no state.
2//
3// DESIGN: conservative on purpose. A false positive costs the model a little context; a pattern that fires on
4// every long hex string or base64 blob would make the mod useless for scientists. So most rules need a distinctive
5// prefix (ghp_, AKIA, sk-ant-, eyJ...), and the generic `password = ...` rule needs a sensitive KEY NAME right before
6// the operator, a value of at least 8 characters, and a value that does not look like a placeholder, a code
7// expression or a plain word. Bare hashes, git SHAs and base64 data have no key name and no prefix, so they pass.
8// Every rule that scans long runs of characters is anchored with a look-behind so a 100 kB base64 line is linear.
9
10export type Hits = Record<string, number>
11
12/** What a rule's `accept` sees of one match. */
13export type Candidate = { whole: string; val: string; pre: string; key: string }
14
15export type Rule = {
16 /** The kind shown in the placeholder `[secret:<kind>]`; a function when it depends on the match. */
17 kind: string | ((c: Candidate) => string)
18 /** Global. Named groups `pre`, `val`, `post`: only `val` is replaced; without them the whole match is. */
19 re: RegExp
20 /** A cheap test that must pass on the text before the (costlier) regex runs. */
21 gate?: RegExp
22 /** Return false to leave this match alone (placeholders, code, plain words). */
23 accept?: (c: Candidate) => boolean
24}
25
26export type Redacted = { text: string; hits: Hits }
27
28const MIN_SCAN = 8 // nothing shorter can hold a secret
29
30/** Runs every rule over the text in order; `hits` counts what was replaced, by kind. */
31export function redact(text: string, rules: readonly Rule[]): Redacted {
32 const hits: Hits = {}
33 if (text.length < MIN_SCAN) return { text, hits }
34 let out = text
35 for (const rule of rules) out = apply(out, rule, hits)
36 return { text: out, hits }
37}
38
39function apply(text: string, rule: Rule, hits: Hits): string {
40 if (rule.gate !== undefined && !rule.gate.test(text)) return text
41 return text.replace(rule.re, (...args: unknown[]) => {
42 const whole = String(args[0])
43 const last = args[args.length - 1]
44 const groups = (typeof last === 'object' && last !== null ? last : {}) as Record<string, string | undefined>
45 const c: Candidate = { whole, val: groups.val ?? whole, pre: groups.pre ?? '', key: groups.key ?? '' }
46 if (rule.accept !== undefined && !rule.accept(c)) return whole
47 const kind = typeof rule.kind === 'string' ? rule.kind : rule.kind(c)
48 hits[kind] = (hits[kind] ?? 0) + 1
49 return `${groups.pre ?? ''}[secret:${kind}]${groups.post ?? ''}`
50 })
51}
52
53export const countOf = (hits: Hits): number => Object.values(hits).reduce((a, b) => a + b, 0)
54
55export function mergeHits(into: Hits, from: Hits): void {
56 for (const [kind, n] of Object.entries(from)) into[kind] = (into[kind] ?? 0) + n
57}
58
59// ---------------------------------------------------------------------------------------------- value checks
60
61const PLACEHOLDER_WORDS = new Set([
62 'password', 'passwd', 'secret', 'token', 'null', 'none', 'nil', 'undefined', 'true', 'false', 'required',
63 'optional', 'string', 'hidden', 'redacted', 'empty', 'unset', 'default', 'changeme', 'ignored', 'masked',
64])
65const PLACEHOLDER_PARTS = ['example', 'your', 'placeholder', 'changeme', 'change-me', 'change_me', 'redacted', 'xxxx', 'dummy', 'sample', 'todo', 'fixme', 'insert', 'replace']
66
67/** Obvious stand-ins: `<token>`, `${TOKEN}`, `$TOKEN`, `%TOKEN%`, `xxxxxxxx`, `YOUR_KEY_HERE`, `********`. */
68export function isPlaceholder(value: string): boolean {
69 const v = value.toLowerCase()
70 if (/^[x*.#_-]+$/.test(v)) return true
71 if (/^[$%<{]|[%>}]$|\$\{|\$\(|\{\{/.test(v)) return true
72 if (v.startsWith('[secret:')) return true
73 if (PLACEHOLDER_WORDS.has(v)) return true
74 return PLACEHOLDER_PARTS.some(p => v.includes(p))
75}
76
77/** Code and plain words that sit after a sensitive key name without being a secret. */
78function looksLikeCode(value: string): boolean {
79 if (/^[A-Za-z_$][\w$]*(?:[?!]?\.[A-Za-z_$][\w$]*)+$/.test(value)) return true // process.env.TOKEN, args.password, opts?.apiKey
80 if (/^(?:[A-Z]{2,}[0-9]?)?(?:[A-Z][a-z]{2,}[0-9]?){2,}$/.test(value)) return true // a type name: HostCancellationToken
81 if (/^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(value)) return true // snake_case_name
82 if (/%[sdifr(]|\{[\w.]*\}/.test(value)) return true // a format string
83 if (/^(?:\.{1,2}|~)?\/[\w.-]+\//.test(value) || /^[A-Za-z]:[\\/]/.test(value)) return true // a file path
84 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return true // a URL (user:password@ is its own rule)
85 if (/^(?:env|vault|ref|file|secrets?):/i.test(value)) return true
86 return /^[A-Za-z_-]+$/.test(value) // letters only: an identifier or a word (a real secret nearly always has a digit)
87}
88
89function acceptAssigned(c: Candidate): boolean {
90 const value = c.val.replace(/[.:!?]+$/, '') // "REQUIRED." in a docstring
91 return !isPlaceholder(value) && !looksLikeCode(value)
92}
93
94/** `password`, `token`, `api-key`... from the key name that preceded the value. */
95function kindOfKey(c: Candidate): string {
96 const k = c.key.toLowerCase()
97 if (k.includes('pass')) return 'password'
98 if (/api[_-]?key/.test(k)) return 'api-key'
99 if (/access[_-]?key/.test(k)) return 'access-key'
100 if (/private[_-]?key/.test(k)) return 'private-key'
101 if (k.includes('token')) return 'token'
102 if (k.includes('secret')) return 'secret'
103 return 'credential'
104}
105
106// ---------------------------------------------------------------------------------------------- the rules
107
108const NOT_ALNUM_BEFORE = '(?<![A-Za-z0-9])'
109
110/** Keys whose value is a secret: the key must END in one of these right before the operator. */
111const SENSITIVE_KEY = '(?:password|passwd|passphrase|secret(?:[_-]?key)?|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credentials?)'
112/** `=`, `:`, `:=`, `=>` and R's `<-`. */
113const OPERATOR = String.raw`[ \t]*(?:=>|:=|<{1,2}-|[:=])[ \t]*`
114const KEY_HEAD = String.raw`(?<![A-Za-z0-9_.-])(?<pre>["']?(?<key>[A-Za-z0-9_.-]*?${SENSITIVE_KEY})["']?${OPERATOR}`
115/**
116 * What may not appear in an unquoted value: whitespace, quotes, brackets, `$`, `\`, pipes, `^ * ?` (regular
117 * expressions and optional chaining). A password with these is quoted in any file a shell reads. `;` is not a
118 * terminator either, so a line like `const token = otherName;` in code is left alone.
119 */
120const BARE_STOP = String.raw`\s"'#,;(){}\[\]<>$\\\x60|&^*?`
121const BARE_VALUE = String.raw`(?<val>[^${BARE_STOP}=:][^${BARE_STOP}]{7,})(?=[\s#,)}\]]|$)`
122const GATE_ASSIGNED = /pass|secret|token|key|credential/i
123
124export const BUILTIN_RULES: readonly Rule[] = [
125 // PEM private keys, whole block; then one whose END line was cut off (a truncated read) with its base64 body.
126 {
127 kind: 'private-key',
128 re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g,
129 gate: /PRIVATE KEY/,
130 },
131 {
132 kind: 'private-key',
133 re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:[ \t]*(?:\r?\n|\\n)[ \t]*[A-Za-z0-9+/=]{16,}[ \t]*)+/g,
134 gate: /PRIVATE KEY/,
135 },
136 // AWS: an access key id with the 40-character secret beside it, then the id alone, then `secret_access_key = ...`.
137 {
138 kind: 'aws-secret-key',
139 re: new RegExp(String.raw`(?<pre>${NOT_ALNUM_BEFORE}(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}["']?[ \t]*[,;:\s][ \t]*["']?)(?<val>[A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])`, 'g'),
140 gate: /AKIA|ASIA|ABIA|ACCA/,
141 accept: c => /[a-z]/.test(c.val) && /[A-Z]/.test(c.val), // not a 40-character hex hash
142 },
143 { kind: 'aws-access-key', re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}(?![A-Za-z0-9])`, 'g'), gate: /AKIA|ASIA|ABIA|ACCA/ },
144 {
145 kind: 'aws-secret-key',
146 re: /(?<pre>secret[_-]?access[_-]?key["']?[ \t]*[:=][ \t]*["']?)(?<val>[A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi,
147 gate: /secret/i,
148 },
149 {
150 kind: 'github-token',
151 re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})(?![A-Za-z0-9_])`, 'g'),
152 gate: /gh[pousr]_|github_pat_/,
153 },
154 { kind: 'gitlab-token', re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}glpat-[A-Za-z0-9_-]{20,}`, 'g'), gate: /glpat-/ },
155 { kind: 'anthropic-key', re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}sk-ant-[A-Za-z0-9_-]{20,}`, 'g'), gate: /sk-ant-/ },
156 {
157 // `sk-proj-...` style, or the older `sk-` + 32 or more letters and digits that include a digit (so `sk-learn` stays).
158 kind: 'openai-key',
159 re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}sk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{32,})(?![A-Za-z0-9_-])`, 'g'),
160 gate: /sk-/,
161 },
162 {
163 kind: 'slack-token',
164 re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}(?:xox[abeprs]-[A-Za-z0-9-]{10,}|xapp-\d-[A-Za-z0-9]+-\d+-[A-Za-z0-9]{10,})`, 'g'),
165 gate: /xox|xapp-/,
166 },
167 { kind: 'slack-webhook', re: /https:\/\/hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9/_-]{20,}/g, gate: /hooks\.slack\.com/ },
168 {
169 kind: 'stripe-key',
170 re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}(?:(?:sk|rk)_(?:live|test)|whsec)_[A-Za-z0-9]{16,}`, 'g'),
171 gate: /sk_|rk_|whsec_/,
172 },
173 { kind: 'huggingface-token', re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}hf_[A-Za-z0-9]{34,}(?![A-Za-z0-9])`, 'g'), gate: /hf_/ },
174 { kind: 'npm-token', re: new RegExp(String.raw`${NOT_ALNUM_BEFORE}npm_[A-Za-z0-9]{36}(?![A-Za-z0-9])`, 'g'), gate: /npm_/ },
175 { kind: 'google-api-key', re: /(?<![A-Za-z0-9_-])AIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g, gate: /AIza/ },
176 {
177 kind: 'jwt',
178 re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{10,}/g,
179 gate: /eyJ/,
180 },
181 {
182 // Authorization: Bearer <token> (also Basic and Token); the header name stays so the line still reads.
183 kind: c => (/bearer/i.test(c.pre) ? 'bearer-token' : 'auth-header'),
184 re: /(?<pre>\bauthorization["']?[ \t]*[:=][ \t]*["']?(?:bearer|basic|token)[ \t]+)(?<val>[A-Za-z0-9._~+/=_-]{16,})/gi,
185 gate: /authorization/i,
186 accept: c => !isPlaceholder(c.val),
187 },
188 {
189 // scheme://user:password@host: only the password goes.
190 kind: 'url-password',
191 re: /(?<pre>(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{1,15}:\/\/[^\s:/@"'<>\x60]{1,100}:)(?<val>[^\s@/"'<>\x60]{3,200})(?<post>@)/g,
192 gate: /:\/\//,
193 accept: c => !isPlaceholder(c.val),
194 },
195 {
196 // password = "...", "api_key": "...", TOKEN: '...'; the key name stays, the quoted value goes.
197 kind: kindOfKey,
198 re: new RegExp(String.raw`${KEY_HEAD}(?<q>["']))(?<val>[^"'\s\\]{8,})(?<post>\k<q>)`, 'gi'),
199 gate: GATE_ASSIGNED,
200 accept: acceptAssigned,
201 },
202 {
203 // PASSWORD=..., token: ... (.env, YAML, R's `<-`); the value must be one 8+ character word, not code.
204 kind: kindOfKey,
205 re: new RegExp(`${KEY_HEAD})${BARE_VALUE}`, 'gi'),
206 gate: GATE_ASSIGNED,
207 accept: acceptAssigned,
208 },
209]
210
211/** Off unless the person turns it on: sample IDs and cluster accounts often look like emails. */
212export const EMAIL_RULE: Rule = {
213 kind: 'email',
214 re: /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}(?![A-Za-z0-9-])/g,
215 gate: /@/,
216 accept: c => !c.whole.toLowerCase().startsWith('git@'), // git@github.com is a remote, not a person
217}
218
219// ---------------------------------------------------------------------------------------------- the person's own patterns
220
221export type Extra = { rules: Rule[]; problems: string[] }
222
223/** `a;;b` or one per line. `/re/i` keeps its flags; anything else is the bare source. A bad one is reported, never thrown. */
224export function parseExtra(source: unknown): Extra {
225 const rules: Rule[] = []
226 const problems: string[] = []
227 if (typeof source !== 'string') return { rules, problems }
228 const items = source.split(/;;|\r?\n/).map(s => s.trim()).filter(s => s !== '')
229 items.forEach((item, i) => {
230 const slashed = /^\/(.+)\/([a-z]*)$/s.exec(item)
231 const label = `extra pattern ${i + 1}`
232 try {
233 const re = new RegExp(slashed ? slashed[1]! : item, [...new Set(`${slashed?.[2] ?? ''}g`)].join(''))
234 if (re.test('')) problems.push(`${label} matches empty text, ignored`)
235 else rules.push({ kind: 'custom', re })
236 } catch (error) {
237 problems.push(`${label} is not a valid regular expression (${firstLine(error)}), ignored`)
238 }
239 })
240 return { rules, problems }
241}
242
243const firstLine = (error: unknown): string => String(error instanceof Error ? error.message : error).split('\n')[0]!.slice(0, 80)
244
245/** The rule set for a configuration: built-ins, emails if asked, then the person's own. */
246export function rulesFor(o: { redactEmails: boolean; extra: readonly Rule[] }): Rule[] {
247 return [...BUILTIN_RULES, ...(o.redactEmails ? [EMAIL_RULE] : []), ...o.extra]
248}
249hooks/reply.ts 24 lines1/**
2 * A reply for a prompt the mod answered itself. The drop reason must be ONE line (the engine draws a
3 * newline as a garbled character), so the first line is the reason and every further line goes to the
4 * transcript as one dim note that is never sent to the model.
5 */
6export function dropWith(ui: { log: (text: string) => void }, text: string): { drop: string } {
7 // eslint-disable-next-line no-control-regex
8 const clean = (line: string): string => line.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/ {2,}/g, ' ').trim()
9 const lines = text
10 .split(/\r\n|\r|\n/)
11 .map(clean)
12 .filter(line => line !== '')
13 const [first = '', ...rest] = lines
14 if (rest.length > 0) {
15 try {
16 ui.log(rest.join('\n'))
17 } catch {
18 // the note is a courtesy: the reason alone still answers
19 }
20 }
21
22 return { drop: first }
23}
24hooks/report.ts 52 lines1// What the mod says. Kinds and counts only: nothing here ever holds a secret.
2
3import { countOf } from './detect'
4import type { Hits } from './detect'
5import type { Tally } from '../types'
6
7export const EMPTY_TALLY: Tally = { tool: {}, typed: {}, held: 0, sentAsTyped: 0, hasAnnounced: false }
8
9const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
10
11/** `github-token 2 · password 1`, most frequent first. */
12export function kindsText(hits: Hits): string {
13 return Object.entries(hits)
14 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
15 .map(([kind, n]) => `${kind} ${n}`)
16 .join(' · ')
17}
18
19/** The one dim line shown the first time something is hidden in a turn. */
20export const announce = (hits: Hits): string => {
21 const n = countOf(hits)
22 return `Secret guard · hid ${plural(n, 'secret', 'secrets')} from tool output`
23}
24
25export type ReportInput = { tally: Tally; isOn: boolean; offBySetting: boolean; problems: readonly string[] }
26
27/** The reply to `/secret-guard`: a headline, then detail lines. */
28export function report(r: ReportInput): string {
29 const hidden = countOf(r.tally.tool) + countOf(r.tally.typed)
30 const state = r.isOn ? 'on' : r.offBySetting ? 'off in settings' : 'off'
31 const head = `Secret guard · ${state} · ${hidden === 0 ? 'nothing hidden yet' : `hid ${plural(hidden, 'secret', 'secrets')} this session`}`
32 const lines = [head]
33 if (countOf(r.tally.tool) > 0) lines.push(`Tool output: ${kindsText(r.tally.tool)}`)
34 const asked = [kindsText(r.tally.typed), r.tally.held > 0 ? `held ${r.tally.held}` : '', r.tally.sentAsTyped > 0 ? `sent as typed ${r.tally.sentAsTyped}` : '']
35 if (asked.some(x => x !== '')) lines.push(`Typed prompts: ${asked.filter(x => x !== '').join(' · ')}`)
36 for (const problem of r.problems) lines.push(`Problem: ${problem}`)
37 lines.push(r.isOn ? 'Type /secret-guard off to pause it.' : r.offBySetting ? 'Turn it on in the plugin settings.' : 'Type /secret-guard on to resume.')
38 return lines.join('\n')
39}
40
41/** The question for a typed prompt: what kinds were found, never the text. */
42export function askText(hits: Hits): string {
43 const n = countOf(hits)
44 return `This prompt seems to contain ${plural(n, 'secret', 'secrets')} (${Object.keys(hits).sort().join(', ')}). How should it be sent?`
45}
46
47export const ASK_HEADER = 'Secret guard'
48export const SEND_REDACTED = 'Send redacted'
49export const SEND_AS_TYPED = 'Send as typed'
50export const HOLD = 'Hold it'
51export const HOLD_REASON = 'secret-guard blocked your prompt · nothing was sent'
52hooks/scrub.ts 76 lines1// Walks tool results and conversation rows and redacts every string in them. Pure: it returns the SAME object
2// when nothing changed (so callers can tell by identity) and a modified copy otherwise; the input is never mutated.
3
4import { mergeHits, redact } from './detect'
5import type { Hits, Rule } from './detect'
6
7/** Keys whose strings are paths, ids or bulk media: scanning them finds nothing and costs time. */
8const SKIP_KEYS = new Set(['base64', 'filePath', 'file_path', 'filenames', 'outputDir', 'rawOutputPath', 'persistedOutputPath', 'tool_use_id'])
9const MEDIA_TYPES = new Set(['image', 'document', 'audio', 'video'])
10const MAX_DEPTH = 16
11
12type Ctx = { rules: readonly Rule[]; hits: Hits }
13
14/** Redacts the strings of any JSON-like value (a tool's record, an MCP result, an error text). */
15export function scrubValue(value: unknown, rules: readonly Rule[], hits: Hits): unknown {
16 return walk(value, { rules, hits }, 0)
17}
18
19function walk(value: unknown, ctx: Ctx, depth: number): unknown {
20 if (typeof value === 'string') return one(value, ctx)
21 if (depth >= MAX_DEPTH || typeof value !== 'object' || value === null) return value
22 if (Array.isArray(value)) return mapArray(value, ctx, depth)
23 const record = value as Record<string, unknown>
24 if (typeof record.type === 'string' && MEDIA_TYPES.has(record.type)) return value
25 let copy: Record<string, unknown> | undefined
26 for (const [key, inner] of Object.entries(record)) {
27 if (SKIP_KEYS.has(key)) continue
28 const next = walk(inner, ctx, depth + 1)
29 if (next !== inner) (copy ??= { ...record })[key] = next
30 }
31 return copy ?? value
32}
33
34function mapArray(items: readonly unknown[], ctx: Ctx, depth: number): unknown {
35 let copy: unknown[] | undefined
36 items.forEach((item, i) => {
37 const next = walk(item, ctx, depth + 1)
38 if (next !== item) (copy ??= [...items])[i] = next
39 })
40 return copy ?? items
41}
42
43function one(text: string, ctx: Ctx): string {
44 const found = redact(text, ctx.rules)
45 mergeHits(ctx.hits, found.hits)
46 return found.text
47}
48
49type Block = { type: string; [field: string]: unknown }
50
51/**
52 * The content blocks of a row. Text blocks and a tool_result's content are redacted; media, thinking and tool_use
53 * blocks are left as they are (the engine puts those back anyway).
54 */
55export function scrubBlocks(blocks: readonly Block[], rules: readonly Rule[], hits: Hits): readonly Block[] {
56 const ctx: Ctx = { rules, hits }
57 let copy: Block[] | undefined
58 blocks.forEach((block, i) => {
59 const next = scrubBlock(block, ctx)
60 if (next !== block) (copy ??= [...blocks])[i] = next
61 })
62 return copy ?? blocks
63}
64
65function scrubBlock(block: Block, ctx: Ctx): Block {
66 if (block.type === 'text' && typeof block.text === 'string') {
67 const text = one(block.text, ctx)
68 return text === block.text ? block : { ...block, text }
69 }
70 if (block.type === 'tool_result') {
71 const content = walk(block.content, ctx, 1)
72 return content === block.content ? block : { ...block, content }
73 }
74 return block
75}
76hooks/words.ts 3 lines1/** True for the person's own typing (the composer or the phone bridge), never a notification, a schedule or a plugin. */
2export const isPeople = (origin: { kind: string }): boolean => origin.kind === 'composer' || origin.kind === 'bridge'
3types/index.d.ts 24 lines1/** Counts of what was hidden, by kind. Kinds and numbers only: a secret is never kept. */
2export type Tally = {
3 /** Hidden in tool output, by kind (`github-token`, `password`...). */
4 tool: Record<string, number>
5 /** Hidden in typed prompts the person chose to send redacted, by kind. */
6 typed: Record<string, number>
7 /** Typed prompts held back. */
8 held: number
9 /** Typed prompts sent as typed after the question. */
10 sentAsTyped: number
11 /** True once this turn's one-line note was shown. */
12 hasAnnounced: boolean
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'secret-guard': {
18 tally: Tally
19 /** The on/off switch as the person last set it; null until read from $.store. */
20 isOn: boolean | null
21 }
22 }
23}
24