SLOPSHOPPER

secret-guard

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…

newguardcommandprompt
★ 1v0.1.1MITupdated 2026-10-12samuelbfernandes/moddeck/mods/secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ● secret-guard: Secret guard · hid 2 secrets from tool output ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-guard ⎿ secret-guard: Secret guard · on · hid 4 secrets this session ⎿ secret-guard: Tool output: stripe-key 2 · url-password 2 ⎿ secret-guard: Type /secret-guard off to pause it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-guard

Install: /plugin install secret-guard --marketplace samuelbfernandes/moddeck

Keeps API keys, tokens, passwords and private keys out of what the model reads.

What it does

  • Tool output. After Read, Bash, Grep, WebFetch and MCP tools run, secret-looking text is replaced by a short placeholder such as [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.
  • Typed prompts. If a prompt you type contains a secret, it asks once: Send redacted, Send as typed, Hold it. Esc or any other answer holds it: 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.
  • Finds: AWS key pairs, GitHub, GitLab, Anthropic, OpenAI, Slack, Stripe, Hugging Face, npm and Google keys, PEM private keys, JWTs, 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.

Use

/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.

Turn off

/secret-guard off, or the enabled setting, or /plugin disable secret-guard.

Settings

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).

Limits

  • Pattern matching only: a secret with no recognizable shape (a passphrase in prose, an unquoted password containing ^ * ? $ ( )) is not found. Keep .env files out of git and out of your prompts anyway.
  • The model sees [secret:...] instead of the value, so if it rewrites a file it read, the placeholder is written.
  • Not covered: @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.
  • Held text is echoed with secrets hidden; use the up arrow in your prompt history to get the original back.
  • Not verified live (the test kit only): macOS and Windows, the question dialog on the desktop app, and that the engine accepts the rewritten result of every tool. Nothing has been run on Windows.
Source 8 files
hooks/register.tsx 167 lines
1// 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}
167
hooks/config.ts 25 lines
1import 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}
25
hooks/detect.ts 249 lines
1// 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}
249
hooks/reply.ts 24 lines
1/**
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}
24
hooks/report.ts 52 lines
1// 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'
52
hooks/scrub.ts 76 lines
1// 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}
76
hooks/words.ts 3 lines
1/** 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'
3
types/index.d.ts 24 lines
1/** 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