SLOPSHOPPER

secret-guard

Detects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history.

newcommandtoastprompt
v0.1.0no licenseupdated 2026-10-08josselinonduty/claude-plugins/plugins/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 ⏺ 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 target: /work/app/.env ⎿ secret-guard: Caught this session: URL_PASSWORD, STRIPE_SECRET_KEY ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-plugins

Claude Code plugins by @josselinonduty.

PluginDescription
commit-changesBand above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages.
secret-guardDetects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history.
prompt-librarySave past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field.

Install

/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-plugins
Source 2 files
hooks/register.tsx 215 lines
1import type { Register } from 'claude-code'
2
3import { detect, mark, redact, toEnvName } from './detect'
4import type { Finding } from './detect'
5
6type Fs = {
7  read: (path: string) => Promise<unknown>
8  write: (path: string, text: string) => Promise<void>
9  exists: (path: string) => Promise<boolean>
10}
11type Env = { fs: Fs; session: { root: () => Promise<string> } }
12
13const quote = (v: string) => (/^[A-Za-z0-9_./:+@-]*$/.test(v) ? v : JSON.stringify(v))
14
15const parseEnv = (text: string) => {
16  const map = new Map<string, string>()
17  for (const line of text.split('\n')) {
18    const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
19    if (!m) continue
20    let v = m[2].trim()
21    if (v.startsWith('"')) {
22      try { v = JSON.parse(v) } catch { /* keep raw */ }
23    } else v = v.replace(/^'(.*)'$/, '$1')
24    map.set(m[1], v)
25  }
26
27  return map
28}
29
30const readText = async ($: Env, path: string) =>
31  (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
32
33/** The project's secrets file: an existing one, else the framework's convention. */
34async function pickEnvFile($: Env) {
35  const root = (await $.session.root()).replace(/\/$/, '')
36  const has = (p: string) => $.fs.exists(`${root}/${p}`)
37  const pkg = await readText($, `${root}/package.json`)
38  const isWorker =
39    (await has('wrangler.toml')) || (await has('wrangler.json')) || (await has('wrangler.jsonc'))
40  const order = isWorker
41    ? ['.dev.vars', '.env']
42    : /"next"\s*:/.test(pkg)
43      ? ['.env.local', '.env']
44      : ['.env', '.env.local']
45  for (const f of order) if (await has(f)) return { root, file: f }
46
47  return { root, file: order[0] }
48}
49
50let queue: Promise<unknown> = Promise.resolve()
51const serial = <T,>(job: () => Promise<T>) => {
52  const run = queue.then(job, job)
53  queue = run.catch(() => undefined)
54
55  return run
56}
57
58const handled = new Map<string, string>() // name -> env file, this session
59
60/** Stores each finding in the env file, returns the variable name used for each. */
61function store($: Env, findings: Finding[]) {
62  return serial(async () => {
63    const { root, file } = await pickEnvFile($)
64    const path = `${root}/${file}`
65    let text = await readText($, path)
66    const env = parseEnv(text)
67    const names: string[] = []
68    const added: string[] = []
69
70    for (const f of findings) {
71      let name = toEnvName(f.name)
72      let n = 1
73      for (;;) {
74        const cur = env.get(name)
75        if (cur === undefined || cur === f.value) break
76        name = `${toEnvName(f.name)}_${++n}`
77      }
78      if (env.get(name) === undefined) {
79        env.set(name, f.value)
80        text += `${text && !text.endsWith('\n') ? '\n' : ''}${name}=${quote(f.value)}\n`
81        added.push(name)
82      }
83      names.push(name)
84      handled.set(name, file)
85    }
86
87    if (added.length > 0) {
88      await $.fs.write(path, text)
89      await keepOutOfGit($, root, file)
90      await addToExample($, root, added)
91    }
92
93    return { names, file, added }
94  })
95}
96
97async function keepOutOfGit($: Env, root: string, file: string) {
98  if (!(await $.fs.exists(`${root}/.git`)) && !(await $.fs.exists(`${root}/.gitignore`))) return
99  const gi = await readText($, `${root}/.gitignore`)
100  const covered = gi.split('\n').some(l => {
101    const p = l.trim().replace(/^\//, '')
102    return p === file || p === '.env*' || (p === '.env.*' && file !== '.env')
103  })
104  if (!covered) await $.fs.write(`${root}/.gitignore`, `${gi}${gi && !gi.endsWith('\n') ? '\n' : ''}${file}\n`)
105}
106
107async function addToExample($: Env, root: string, names: string[]) {
108  const path = `${root}/.env.example`
109  if (!(await $.fs.exists(path))) return
110  let text = await readText($, path)
111  const have = parseEnv(text)
112  for (const n of names) {
113    if (have.has(n)) continue
114    text += `${text && !text.endsWith('\n') ? '\n' : ''}${n}=\n`
115  }
116  await $.fs.write(path, text)
117}
118
119/** Redacts `text`; the secrets go to the env file. If that fails they are still redacted. */
120async function guard($: Env, text: string) {
121  const findings = detect(text)
122  if (findings.length === 0) return { text, file: undefined, names: [] as string[] }
123  try {
124    const { names, file } = await store($, findings)
125
126    return { text: redact(text, findings, names), file, names }
127  } catch {
128    const names = findings.map(f => toEnvName(f.name))
129
130    return { text: redact(text, findings, names), file: undefined, names }
131  }
132}
133
134/** Scrubs only, no I/O: the fallback when a hook failed. */
135const scrub = (text: string) => {
136  const f = detect(text)
137
138  return f.length === 0 ? text : redact(text, f, f.map(x => toEnvName(x.name)))
139}
140
141type Block = { type: string; text?: string; content?: unknown; [k: string]: unknown }
142
143async function mapBlocks(blocks: Block[], fn: (t: string) => Promise<string> | string): Promise<Block[]> {
144  return Promise.all(
145    blocks.map(async b => {
146      if (b.type === 'text' && typeof b.text === 'string') return { ...b, text: await fn(b.text) }
147      if (b.type === 'tool_result') {
148        if (typeof b.content === 'string') return { ...b, content: await fn(b.content) }
149        if (Array.isArray(b.content)) return { ...b, content: await mapBlocks(b.content as Block[], fn) }
150      }
151
152      return b
153    }),
154  )
155}
156
157export const register: Register = on => {
158  on('session.start', async ($, e, next) => {
159    await $.command.register({
160      name: 'secret-guard',
161      description: 'Show where secret-guard stores the secrets it has caught',
162    })
163
164    return next(e)
165  })
166
167  on('command.run', { command: 'secret-guard' }, async $ => {
168    const { root, file } = await pickEnvFile($ as unknown as Env)
169    const names = [...handled.keys()]
170
171    return {
172      text:
173        `secret-guard target: ${root}/${file}\n` +
174        (names.length ? `Caught this session: ${names.join(', ')}` : 'No secret caught this session.'),
175    }
176  })
177
178  // The prompt: redact before the model reads it or the queue records it.
179  on('prompt.submit', async ($, e, next) => {
180    const r = await guard($ as unknown as Env, e.text)
181    if (r.names.length > 0) {
182      void $.ui.toast(
183        r.file
184          ? `secret-guard: ${r.names.join(', ')} moved to ${r.file}`
185          : `secret-guard: redacted ${r.names.length} secret(s); could not write the env file`,
186      )
187    }
188
189    return next({
190      ...e,
191      text: r.text,
192      context:
193        r.names.length > 0
194          ? [
195              ...(e.context ?? []),
196              `secret-guard replaced secret(s) in the user's prompt with markers like ${mark('NAME')}. ` +
197                `Their values are in ${r.file ?? 'the project env file'} as NAME; reference them through the environment, never ask for or echo them.`,
198            ]
199          : e.context,
200    })
201  }).catch(($, e, next) => next.called ? next(e) : next({ ...e, text: scrub(e.text) }))
202
203  // Every row the conversation keeps: tool output (cat .env), model text, notices.
204  on('session.append', async ($, e, next) => {
205    const content = await mapBlocks(e.message.content as Block[], async t => (await guard($ as unknown as Env, t)).text)
206
207    return next({ ...e, message: { ...e.message, content: content as typeof e.message.content } })
208  }).catch(async ($, e, next) => {
209    if (next.called) return next(e)
210    const content = await mapBlocks(e.message.content as Block[], scrub)
211
212    return next({ ...e, message: { ...e.message, content: content as typeof e.message.content } })
213  })
214}
215
hooks/detect.ts 100 lines
1export type Finding = { start: number; end: number; value: string; name: string }
2
3type Rule = {
4  name: string
5  re: RegExp
6  group?: number
7  nameGroup?: number
8  /** Rejects a match given the text just before it. */
9  skip?: (value: string, before: string) => boolean
10}
11
12const HASH_CONTEXT = /(?:sha\d*|md5|commit|digest|integrity|checksum|hash|rev|uuid)\W{0,4}$/i
13const inMarker = (_: string, before: string) => /\[secret:[A-Za-z0-9_]*$/.test(before)
14
15const RULES: Rule[] = [
16  { name: 'PRIVATE_KEY', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g },
17  { name: 'ANTHROPIC_API_KEY', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
18  { name: 'OPENAI_API_KEY', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
19  { name: 'GITHUB_TOKEN', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})/g },
20  { name: 'AWS_ACCESS_KEY_ID', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
21  { name: 'GOOGLE_API_KEY', re: /\bAIza[0-9A-Za-z_-]{35}/g },
22  { name: 'SLACK_TOKEN', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
23  { name: 'STRIPE_SECRET_KEY', re: /\b[sr]k_(?:live|test)_[0-9a-zA-Z]{20,}/g },
24  { name: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
25  { name: 'URL_PASSWORD', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:([^\s@/]{3,})@/gi, group: 1 },
26  {
27    name: '',
28    re: /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|ACCESS_KEY|CREDENTIAL)[A-Za-z0-9_]*)\s*[=:]\s*["']?([^\s"',;]{8,})/gi,
29    group: 2,
30    nameGroup: 1,
31    skip: (v, before) => v.endsWith(']') && before.endsWith('['),
32  },
33  // Anything left that looks encoded: 11+ hex or base64 chars. The mix of classes
34  // keeps plain words, numbers and lower-case ids (uuids, slugs) out.
35  {
36    name: 'HEX_SECRET',
37    re: /(?<![A-Za-z0-9_.+\/=-])[0-9a-fA-F]{11,}(?![A-Za-z0-9_+\/-])/g,
38    skip: (v, before) => !/\d/.test(v) || !/[a-fA-F]/.test(v) || HASH_CONTEXT.test(before) || inMarker(v, before),
39  },
40  {
41    name: 'BASE64_SECRET',
42    re: /(?<![A-Za-z0-9_.+\/=-])[A-Za-z0-9+\/_-]{11,}={0,2}(?![A-Za-z0-9_+\/=-])/g,
43    skip: (v, before) =>
44      !/\d/.test(v) || !/[a-z]/.test(v) || !/[A-Z]/.test(v) || /^[A-Za-z]+\d*$/.test(v) ||
45      (v.includes('/') && !v.includes('+') && !v.endsWith('=')) ||
46      HASH_CONTEXT.test(before) || inMarker(v, before),
47  },
48]
49
50const PLACEHOLDER =
51  /^(?:\$\{?\w+\}?|<.*>|\[.*\]|your[_-]|xxx|\*{3,}|changeme|example|placeholder|process\.env|env\.|os\.environ|null$|undefined$|true$|false$)/i
52
53/** Marker the redaction leaves in the history; never matches a rule. */
54export const mark = (name: string) => `[secret:${name}]`
55
56const isPlaceholder = (value: string) => PLACEHOLDER.test(value) || value.startsWith('[secret:')
57
58export function toEnvName(raw: string) {
59  return raw.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase() || 'SECRET'
60}
61
62export function detect(text: string): Finding[] {
63  const found: Finding[] = []
64
65  for (const rule of RULES) {
66    for (const m of text.matchAll(rule.re)) {
67      const value = m[rule.group ?? 0]
68      if (value === undefined || isPlaceholder(value)) continue
69      if (rule.skip?.(value, text.slice(Math.max(0, (m.index ?? 0) - 24), m.index ?? 0))) continue
70      const offset = rule.group === undefined ? 0 : m[0].indexOf(value, rule.nameGroup ? m[rule.nameGroup].length : 0)
71      const start = (m.index ?? 0) + offset
72      const name = rule.nameGroup ? toEnvName(m[rule.nameGroup]) : rule.name
73      found.push({ start, end: start + value.length, value, name })
74    }
75  }
76
77  // Earliest first; on overlap keep the longer (a PEM block beats a keyword inside it).
78  found.sort((a, b) => a.start - b.start || b.end - a.end)
79  const kept: Finding[] = []
80  for (const f of found) {
81    const last = kept[kept.length - 1]
82    if (last && f.start < last.end) continue
83    kept.push(f)
84  }
85
86  return kept
87}
88
89/** Rewrites `text`, swapping each finding for `names[i]`'s marker. */
90export function redact(text: string, findings: Finding[], names: string[]) {
91  let out = ''
92  let at = 0
93  findings.forEach((f, i) => {
94    out += text.slice(at, f.start) + mark(names[i])
95    at = f.end
96  })
97
98  return out + text.slice(at)
99}
100