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

Claude Code plugins by @josselinonduty.
| Plugin | Description |
|---|---|
commit-changes | Band above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages. |
secret-guard | Detects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history. |
prompt-library | Save past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field. |
/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-pluginshooks/register.tsx 215 lines1import 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}
215hooks/detect.ts 100 lines1export 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