Keeps the secrets you paste out of Claude's context and the transcript, stores them per project in the macOS Keychain, and hands them to Bash as environment…

Paste an API key into Claude Code and keep working. secret-keeper hides the key before Claude or the transcript sees it, stores it in your macOS Keychain for this project, and exports it to every Bash command as an environment variable. Claude writes $ELEVENLABS_API_KEY, your scripts read process.env.ELEVENLABS_API_KEY, and the value never enters the conversation.
you paste: ELEVENLABS_API_KEY=sk_…
Claude sees: ELEVENLABS_API_KEY=[secret:ELEVENLABS_API_KEY]
Claude runs: bun run tts.ts
Bash runs: export ELEVENLABS_API_KEY="$SECRET_KEEPER_1"; bun run tts.ts
with SECRET_KEEPER_1 holding the value for that call only
Built and tested on Claude Code 2.1.303.
/plugin install secret-keeper --marketplace JesseVent/claude-mods
or from a shell: claude plugin marketplace add JesseVent/claude-mods, then claude plugin install secret-keeper@claude-mods, then /reload-plugins in an open session.
[secret:NAME]. The name is the one you pasted it with (NAME=value, export NAME=…, "NAME": "…"). With no name, it's the provider's usual variable, such as OPENAI_API_KEY or GITHUB_TOKEN. The value goes to the login Keychain (service claude-secret-keeper), on stdin, never in a process argument. Pasting a new value under a name you already stored replaces it./secrets global NAME shares one with every project.SECRET_KEEPER_1 to SECRET_KEEPER_16 hold this project's secrets.export GITHUB_TOKEN="$SECRET_KEEPER_1" NPM_TOKEN="$SECRET_KEEPER_2";.Bash(git push:*) still match, because the prefix is ordinary exports.$NAME.[secret:NAME] Claude writes into a command becomes "${NAME}".Edit, Write and NotebookEdit, [secret:NAME] is written as the real value, so NAME=[secret:NAME] in a .env file works, and so does an edit to a line that holds a key./secrets | What's stored for this project and globally, plus a count for other projects and what this session hid |
/secrets rm NAME | Remove one (this project's first, then the global one) |
/secrets rm all | Remove every secret secret-keeper stored, in every project |
/secrets global NAME | Share this project's secret with every project |
/secrets local NAME | Make a global secret this project's only |
Set in /plugin; they're stored under pluginConfigs in settings.
pii (default false): also hide personal data, checking the check digit where there is one:Each appears as [pii:EMAIL#1]. It's never stored or exported, and a Bash command that names one is refused.
keychain (default true): off keeps pasted secrets for the session only.off (default empty): comma-separated rule ids to skip, for example assignment,jwt.Providers, by documented prefix: anthropic, openai, openai-scoped, openrouter, elevenlabs, deepseek, aws-access-key, github, github-fine-grained, gitlab, slack, slack-webhook, stripe, stripe-webhook, sendgrid, twilio, google, google-oauth, supabase-pat, supabase-secret, posthog, xai, groq, replicate, perplexity, huggingface, npm, pypi, linear, notion, resend, shopify, databricks, sentry, discord-bot, discord-webhook, telegram, mailgun, mailchimp, doppler, digitalocean, mapbox, fireworks, figma, atlassian, square, age.
By structure:
private-key: a PEM block;private-key-partial: a PEM block cut off before its END line;jwt;url-credentials: a URL with a real password. Local defaults like postgres:postgres@ are left alone.auth-header: an Authorization: Bearer … value;api-key-header: an x-api-key: … value.assignment: a value assigned to a name that says it's secret (*_API_KEY, *_SECRET, *_TOKEN, PASSWORD, …). It has no prefix to go on, so the value must also look generated:
EthereumWeb3Credentials);$VAR, process.env.X, changeme, your-api-key, test, …);False positives. A sweep of 154,425 files (1.07 GB: a node_modules tree, a large TypeScript codebase, an archived app with its dependencies) gave these results:
Edit still restores them.❯ ./register.ts hooks: session.start, prompt.submit, session.append, tool.call{tool=Edit}, tool.call{tool=Write}, tool.call{tool=NotebookEdit}, tool.call{tool=Bash}, prompt.section{name=env_info_simple}, command.run{command=secrets} ❯ ./register.ts calls: $.command.register, $.env.set (via setSlot), $.process.run (via ensure, runner), $.session.cwd (via ensure), $.state.get, $.state.set, $.store.get (via getIndex), $.store.set, $.ui.invalidate, $.ui.log ❯ ./register.ts env writes: SECRET_KEEPER_1, SECRET_KEEPER_10, SECRET_KEEPER_11, SECRET_KEEPER_12, SECRET_KEEPER_13, SECRET_KEEPER_14, SECRET_KEEPER_15, SECRET_KEEPER_16, SECRET_KEEPER_2, SECRET_KEEPER_3, SECRET_KEEPER_4, SECRET_KEEPER_5, SECRET_KEEPER_6, SECRET_KEEPER_7, SECRET_KEEPER_8, SECRET_KEEPER_9 ❯ ./register.ts env reads: nothing ❯ ./register.ts state writes: secret-keeper.exported, secret-keeper.project, secret-keeper.vault ❯ ./register.ts state reads: secret-keeper.exported, secret-keeper.project, secret-keeper.vault
It runs two local programs and makes no network calls:
git rev-parse --show-toplevel, once per session, to find the project;/usr/bin/security, to add, find and delete its own Keychain items.$.store holds an index of names, scopes and dates, never values.
curl … -d "$OPENAI_API_KEY"). Turn on /sandbox with a network allowlist; that's what stops the send. Keeping secrets per project limits how many one session holds.export NAME="$SECRET_KEEPER_n" prefix.Edit, Write and NotebookEdit see restored values, because the value has to reach the tool for the file to get it./usr/bin/security, so any script you run can read them without a prompt, the same as your .env files. Claude's shell is refused any command that names the service.NAME=value and the name carries it. When Claude does see one in plain text, the note tells it to say so once.Write record keeps the file as written, with the value in it.prompt.submit doesn't rewrite commands). Paste keys as a normal message.[secret:NAME] inside single quotes in a shell command stays literal, as $NAME would.Run /secrets rm all, then disable it in /plugin. Disabling it alone leaves the items in your login Keychain under the service claude-secret-keeper.
hooks/register.ts 430 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StoredEntry } from '../types'
5import { find, type FindOptions, type Hit } from './detect'
6import * as keychain from './keychain'
7import { prefixFor, refsToVars, SLOTS, TOKEN, tokensIn } from './shell'
8
9/** Placeholder key (`secret:NAME`, `pii:EMAIL#1`) → the real value, for this session. */
10const vault = atom({ plugin: 'secret-keeper', key: 'vault' } as const, {} as Record<string, string>)
11/** Names of stored secrets this session exports to Bash. */
12const exported = atom({ plugin: 'secret-keeper', key: 'exported' } as const, [] as string[])
13/** The project this session belongs to: its root and the hash Keychain accounts use. */
14const project = atom({ plugin: 'secret-keeper', key: 'project' } as const, { id: '', root: '' })
15
16type Project = { id: string; root: string }
17type Index = Record<string, StoredEntry>
18type Block = { type: string; [field: string]: unknown }
19type Cfg = FindOptions & { keychain: boolean }
20
21const GLOBAL = 'global'
22
23// Bash calls running now: the slots are set while any is, so hooks, MCP servers and commands
24// started between calls never inherit them.
25let running = 0
26let slotsSet = 0
27let envRefused = false
28let overflowSaid = false
29
30/** Sets slot `i` (0-based) to a value, or unsets it. `$.env.set` takes literal names only, hence the switch. */
31function setSlot($: EngineInterface, i: number, value: string | undefined) {
32 switch (i) {
33 case 0: return $.env.set('SECRET_KEEPER_1', value)
34 case 1: return $.env.set('SECRET_KEEPER_2', value)
35 case 2: return $.env.set('SECRET_KEEPER_3', value)
36 case 3: return $.env.set('SECRET_KEEPER_4', value)
37 case 4: return $.env.set('SECRET_KEEPER_5', value)
38 case 5: return $.env.set('SECRET_KEEPER_6', value)
39 case 6: return $.env.set('SECRET_KEEPER_7', value)
40 case 7: return $.env.set('SECRET_KEEPER_8', value)
41 case 8: return $.env.set('SECRET_KEEPER_9', value)
42 case 9: return $.env.set('SECRET_KEEPER_10', value)
43 case 10: return $.env.set('SECRET_KEEPER_11', value)
44 case 11: return $.env.set('SECRET_KEEPER_12', value)
45 case 12: return $.env.set('SECRET_KEEPER_13', value)
46 case 13: return $.env.set('SECRET_KEEPER_14', value)
47 case 14: return $.env.set('SECRET_KEEPER_15', value)
48 default: return $.env.set('SECRET_KEEPER_16', value)
49 }
50}
51
52const write = ($: EngineInterface, i: number, value: string | undefined) =>
53 Promise.resolve(setSlot($, i, value)).catch((err: unknown) => ({ deny: String(err) }))
54
55/** Fills the first slots with values, counting each as it lands; resolves with why not when a write is refused. */
56async function fillSlots($: EngineInterface, values: readonly string[]): Promise<string | undefined> {
57 for (let i = 0; i < values.length; i++) {
58 const r = await write($, i, values[i])
59 if (r.deny) return r.deny
60 slotsSet = Math.max(slotsSet, i + 1)
61 }
62 return undefined
63}
64
65/** Unsets every slot set; one that will not unset stays counted, to try again. Resolves with why, if any did not. */
66async function clearSlots($: EngineInterface): Promise<string | undefined> {
67 let failed: string | undefined
68 let left = 0
69 for (let i = 0; i < slotsSet; i++) {
70 const r = await write($, i, undefined)
71 if (r.deny) {
72 failed ??= r.deny
73 left = i + 1
74 }
75 }
76 slotsSet = left
77 return failed
78}
79const WITHHELD = '[secret-keeper withheld this content: it could not be scanned]'
80const UNCHECKED = 'secret-keeper could not check this call for secrets, so it was not run.'
81
82// Fail closed: a row that could not be scanned is never stored or sent as it came.
83const withheld = (blocks: readonly Block[]) =>
84 blocks.map(b => (b.type === 'text' ? { ...b, text: WITHHELD } : b.type === 'tool_result' ? { ...b, content: WITHHELD } : b))
85
86const fnv1a = (s: string) => {
87 let h = 0x811c9dc5
88 for (const c of s) h = Math.imul(h ^ c.codePointAt(0)!, 0x01000193) >>> 0
89 return h.toString(16).padStart(8, '0')
90}
91const today = () => new Date().toLocaleDateString('en-CA')
92const runner = ($: EngineInterface): Parameters<typeof keychain.save>[0] => (argv, init) => $.process.run(argv, init)
93const getIndex = async ($: EngineInterface) => ((await $.store.get('index')) ?? {}) as Index
94
95function note(names: readonly string[], keychainOn: boolean): string {
96 return [
97 'Secret handling (secret-keeper): secrets in this conversation appear as [secret:NAME], and personal data as [pii:LABEL#n]; the real values never reach you.',
98 keychainOn
99 ? 'A key the person pastes is hidden before you see it and saved to their macOS Keychain, so pasting is safe and expected: do not warn them about it or suggest rotating it.'
100 : 'A key the person pastes is hidden before you see it, so pasting is safe and expected: do not warn them about it or suggest rotating it.',
101 'Stored secrets are already exported as environment variables in every Bash command: write $NAME, and scripts read them from the environment too.',
102 'In Edit, Write and NotebookEdit, writing [secret:NAME] writes the real value (for a .env file, write NAME=[secret:NAME]).',
103 'Never ask for a hidden value or try to reconstruct one. If you see what looks like a real secret in plain text, it was missed: say so once, briefly, so they can rotate it.',
104 names.length ? `Available in Bash: ${names.join(', ')}.` : 'No secrets are stored for this project yet.',
105 ].join(' ')
106}
107
108/** Every encoding of a value that output could carry it in. */
109function forms(value: string): string[] {
110 const bytes = new TextEncoder().encode(value)
111 let bin = ''
112 for (const b of bytes) bin += String.fromCharCode(b)
113 const b64 = btoa(bin)
114 const url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
115 const json = JSON.stringify(value).slice(1, -1)
116 return [...new Set([value, json, b64, url, keychain.toHex(value)])].filter(f => f.length >= 8)
117}
118
119/** The key a hit is kept under. Same value, same key; a pasted value takes its name over, a found one gets a suffix. */
120function assign(v: Record<string, string>, hit: Hit, pasted: boolean): string {
121 const known = Object.keys(v).find(k => v[k] === hit.value)
122 if (known) return known
123 let key: string
124 if (hit.kind === 'pii') key = `pii:${hit.name}#${Object.keys(v).filter(k => k.startsWith(`pii:${hit.name}#`)).length + 1}`
125 else {
126 key = `secret:${hit.name}`
127 for (let i = 2; !pasted && v[key] !== undefined; i++) key = `secret:${hit.name}_${i}`
128 }
129 v[key] = hit.value
130 return key
131}
132
133/** Hides known values (in any encoding), then new finds. Adds new finds to `v`; returns the text and what was new. */
134function scrub(text: string, v: Record<string, string>, cfg: Cfg, pasted: boolean) {
135 const needles = Object.entries(v).flatMap(([key, value]) => forms(value).map(f => [f, `[${key}]`] as const))
136 needles.sort((a, b) => b[0].length - a[0].length)
137 for (const [needle, token] of needles) if (text.includes(needle)) text = text.split(needle).join(token)
138
139 const fresh: Array<{ key: string; hit: Hit; replaced: boolean }> = []
140 let out = ''
141 let at = 0
142 for (const hit of find(text, cfg)) {
143 const replaced = pasted && hit.kind === 'secret' && v[`secret:${hit.name}`] !== undefined && v[`secret:${hit.name}`] !== hit.value
144 const isNew = !Object.values(v).includes(hit.value)
145 const key = assign(v, hit, pasted)
146 if (isNew || pasted) fresh.push({ key, hit, replaced })
147 out += `${text.slice(at, hit.start)}[${key}]`
148 at = hit.end
149 }
150 return { text: out + text.slice(at), fresh }
151}
152
153function mapBlocks(blocks: readonly Block[], fn: (text: string) => string): Block[] | undefined {
154 let changed = false
155 const out = blocks.map(b => {
156 if (b.type === 'text' && typeof b.text === 'string') {
157 const text = fn(b.text)
158 if (text !== b.text) return (changed = true), { ...b, text }
159 }
160 if (b.type === 'tool_result' && typeof b.content === 'string') {
161 const content = fn(b.content)
162 if (content !== b.content) return (changed = true), { ...b, content }
163 }
164 if (b.type === 'tool_result' && Array.isArray(b.content)) {
165 const content = mapBlocks(b.content as Block[], fn)
166 if (content) return (changed = true), { ...b, content }
167 }
168 return b
169 })
170 return changed ? out : undefined
171}
172
173/** Placeholders in tool arguments swapped for real values; one this session does not hold refuses the call. */
174function restore(v: Record<string, string>, fields: Record<string, string | undefined>) {
175 const unknown = Object.values(fields).flatMap(f => (f ? tokensIn(f) : [])).filter(k => v[k] === undefined)
176 if (unknown.length) {
177 return {
178 deny: `[${unknown[0]}] is not a value this session holds (it may come from a session that was cleared). Ask the person to paste it again, or leave that text as it is.`,
179 }
180 }
181 const out: Record<string, string> = {}
182 for (const [k, f] of Object.entries(fields)) if (f !== undefined) out[k] = f.replace(TOKEN, (_, key: string) => v[key]!)
183 return { fields: out, changed: Object.entries(out).some(([k, f]) => f !== fields[k]) }
184}
185
186async function loadStored($: EngineInterface, p: Project) {
187 const index = await getIndex($)
188 const mine = Object.entries(index).filter(([, e]) => e.scope === GLOBAL || e.scope === p.root)
189 // A project's own key wins over a global one with the same name.
190 mine.sort(([, a], [, b]) => Number(a.scope !== GLOBAL) - Number(b.scope !== GLOBAL))
191 const values = await Promise.all(mine.map(([acct]) => keychain.load(runner($), acct)))
192 const add: Record<string, string> = {}
193 mine.forEach(([, e], i) => values[i] !== undefined && (add[`secret:${e.name}`] = values[i]!))
194 if (Object.keys(add).length === 0) return
195 await update($, vault, v => ({ ...v, ...add }))
196 await update($, exported, names => [...new Set([...names, ...Object.keys(add).map(k => k.slice('secret:'.length))])])
197}
198
199/** Works out the project once per session and loads its stored secrets; every hook calls it, since /clear starts state over. */
200async function ensure($: EngineInterface, cfg: Cfg, cwd?: string): Promise<Project> {
201 const current = await read($, project)
202 if (current.id) return current
203 const dir = cwd ?? (await $.session.cwd())
204 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 5_000 }).catch(() => undefined)
205 const root = git?.exitCode === 0 && git.stdout.trim() ? git.stdout.trim() : dir
206 const mine = { id: fnv1a(root), root }
207 let claimed = false
208 await update($, project, p => (p.id ? p : ((claimed = true), mine)))
209 if (claimed && cfg.keychain) await loadStored($, mine).catch(err => $.ui.log(`could not read stored secrets from the Keychain: ${String(err)}`))
210 return (await read($, project)) as Project
211}
212
213/** Saves to the Keychain and indexes the name; returns why it failed, if it did. */
214async function store($: EngineInterface, p: Project, name: string, value: string): Promise<string | undefined> {
215 const acct = keychain.account(p.id, name)
216 try {
217 await keychain.save(runner($), acct, value)
218 } catch (err) {
219 return String(err instanceof Error ? err.message : err)
220 }
221 await $.store.set('index', { ...(await getIndex($)), [acct]: { name, scope: p.root, saved: today() } })
222 return undefined
223}
224
225export const register: Register = (on, options) => {
226 const cfg: Cfg = {
227 pii: options.pii === true,
228 keychain: options.keychain !== false,
229 off: new Set(String(options.off ?? '').split(',').map(s => s.trim()).filter(Boolean)),
230 }
231
232 on('session.start', async ($, e, next) => {
233 await $.command.register({
234 name: 'secrets',
235 description: 'List the secrets stored for this project; rm, global or local one',
236 argumentHint: '[rm <NAME|all> | global <NAME> | local <NAME>]',
237 })
238 await ensure($, cfg, e.cwd)
239 return next(e)
240 })
241
242 on('prompt.submit', async ($, e, next) => {
243 if (e.command) return next(e)
244 const p = await ensure($, cfg)
245 const v = { ...(await read($, vault)) }
246 const { text, fresh } = scrub(e.text, v, cfg, true)
247 if (fresh.length === 0 && text === e.text) return next(e)
248
249 const said: string[] = []
250 const stored: string[] = []
251 for (const { key, hit, replaced } of fresh) {
252 if (hit.kind === 'pii') continue
253 const name = key.slice('secret:'.length)
254 if (!cfg.keychain) {
255 said.push(`hid ${name} for this session`)
256 stored.push(name)
257 continue
258 }
259 const failed = await store($, p, name, hit.value)
260 said.push(failed ? `kept ${name} for this session only (the Keychain refused it: ${failed})` : `${replaced ? 'replaced' : 'stored'} ${name} in the Keychain for this project`)
261 stored.push(name)
262 }
263 const pii = fresh.filter(f => f.hit.kind === 'pii').length
264 if (pii) said.push(`hid ${pii} personal ${pii === 1 ? 'detail' : 'details'}`)
265
266 await update($, vault, cur => ({ ...cur, ...v }))
267 if (stored.length) {
268 await update($, exported, names => [...new Set([...names, ...stored])])
269 $.ui.invalidate('prompt.section')
270 }
271 if (said.length) $.ui.log(said.join('; '))
272 return next({ ...e, text })
273 }).catch(($, e, next) => (next.called ? next(e) : { drop: 'secret-keeper could not scan this prompt, so it was not sent. Send it again, or turn the mod off in /plugin.' }))
274
275 on('session.append', async ($, e, next) => {
276 try {
277 await ensure($, cfg)
278 const v = { ...(await read($, vault)) }
279 const before = Object.keys(v).length
280 const content = mapBlocks(e.message.content as Block[], t => scrub(t, v, cfg, false).text)
281 if (Object.keys(v).length !== before) await update($, vault, cur => ({ ...v, ...cur }))
282 return next(content ? { ...e, message: { ...e.message, content } } : e)
283 } catch (err) {
284 $.ui.log(`withheld a row it could not scan: ${String(err)}`, { to: 'debug' })
285 return next({ ...e, message: { ...e.message, content: withheld(e.message.content as Block[]) } })
286 }
287 }).catch(($, e, next) => (next.called ? next(e) : next({ ...e, message: { ...e.message, content: withheld(e.message.content as Block[]) } })))
288
289 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
290 await ensure($, cfg)
291 const r = restore(await read($, vault), { old_string: e.old_string, new_string: e.new_string })
292 if ('deny' in r) return { deny: r.deny! }
293 return r.changed ? next({ ...e, ...r.fields }) : next(e)
294 }).catch(($, e, next) => (next.called ? next(e) : { deny: UNCHECKED }))
295
296 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
297 await ensure($, cfg)
298 const r = restore(await read($, vault), { content: e.content })
299 if ('deny' in r) return { deny: r.deny! }
300 return r.changed ? next({ ...e, ...r.fields }) : next(e)
301 }).catch(($, e, next) => (next.called ? next(e) : { deny: UNCHECKED }))
302
303 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
304 await ensure($, cfg)
305 const r = restore(await read($, vault), { new_source: e.new_source })
306 if ('deny' in r) return { deny: r.deny! }
307 return r.changed ? next({ ...e, ...r.fields }) : next(e)
308 }).catch(($, e, next) => (next.called ? next(e) : { deny: UNCHECKED }))
309
310 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
311 if (e.command.includes(keychain.SERVICE)) return { deny: 'Stored secrets are read by secret-keeper alone; use them as $NAME.' }
312 await ensure($, cfg)
313 const v = await read($, vault)
314 const names = await read($, exported)
315 const blocked = tokensIn(e.command).filter(k => !k.startsWith('secret:') || !names.includes(k.slice('secret:'.length)))
316 if (blocked.length) {
317 return {
318 deny: `[${blocked[0]}] is not available in the shell: only secrets the person pasted or stored are, as $NAME. Read a value found in a file from that file (for example \`set -a; . ./.env\`), and keep personal data out of commands.`,
319 }
320 }
321 const command = refsToVars(e.command)
322 const plain = () => (command === e.command ? next(e) : next({ ...e, command }))
323 const have = [...names].sort().filter(n => v[`secret:${n}`] !== undefined)
324 // Slots an earlier unset left behind get another try before anything else runs.
325 if (running === 0 && slotsSet > 0) await clearSlots($)
326 if (have.length === 0 || envRefused) return plain()
327 if (have.length > SLOTS && !overflowSaid) {
328 overflowSaid = true
329 $.ui.log(`only ${SLOTS} stored secrets fit in a Bash call; ${have.slice(SLOTS).join(', ')} ${have.length - SLOTS === 1 ? 'is' : 'are'} left out`)
330 }
331 const used = have.slice(0, SLOTS)
332
333 running++
334 try {
335 const refused = await fillSlots($, used.map(n => v[`secret:${n}`]!))
336 if (refused) {
337 // Never fall back to putting values in the command: hooks beneath would see and might log them.
338 envRefused = true
339 $.ui.log(`stored secrets are not available in Bash this session: the environment write was refused (${refused})`)
340 return await plain()
341 }
342 return await next({ ...e, command: prefixFor(used) + command })
343 } finally {
344 const stuck = --running === 0 ? await clearSlots($) : undefined
345 if (stuck) {
346 // Say so, and stop handing secrets to Bash: a value left in Claude Code's environment reaches whatever it starts next.
347 envRefused = true
348 $.ui.log(`a stored secret could not be cleared from Claude Code's environment (${stuck}); stored secrets stay out of Bash for this session, and a restart drops it`)
349 }
350 }
351 }).catch(($, e, next) => (next.called ? next(e) : { deny: UNCHECKED }))
352
353 on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
354 const r = await next(e)
355 await ensure($, cfg)
356 return { text: [r.text, note(await read($, exported), cfg.keychain)].filter(Boolean).join('\n\n') }
357 })
358
359 on('command.run', { command: 'secrets' }, async ($, e) => {
360 const p = await ensure($, cfg)
361 const [verb = '', name = ''] = e.args.trim().split(/\s+/)
362 const index = await getIndex($)
363 const label = (scope: string) => (scope === GLOBAL ? 'global' : scope === p.root ? 'this project' : scope)
364
365 if (!verb) {
366 const here = Object.values(index).filter(x => x.scope === GLOBAL || x.scope === p.root)
367 const elsewhere = Object.keys(index).length - here.length
368 const v = await read($, vault)
369 const names = await read($, exported)
370 const found = Object.keys(v).filter(k => k.startsWith('secret:') && !names.includes(k.slice('secret:'.length))).length
371 const pii = Object.keys(v).filter(k => k.startsWith('pii:')).length
372 const width = Math.max(0, ...here.map(x => x.name.length))
373 return {
374 text: [
375 !cfg.keychain
376 ? 'The Keychain is off: pasted secrets last this session only.'
377 : here.length
378 ? ['Stored in the Keychain:', ...here.map(x => ` ${x.name.padEnd(width)} ${label(x.scope)} ${x.saved}`)].join('\n')
379 : 'Nothing stored for this project. Paste a key (NAME=value works best) and it is kept for this project.',
380 elsewhere ? `${elsewhere} more stored for other projects.` : '',
381 found || pii ? `Hidden this session: ${found} found in files (not stored), ${pii} personal.` : '',
382 ]
383 .filter(Boolean)
384 .join('\n'),
385 }
386 }
387
388 if (verb === 'rm' && name === 'all') {
389 const accts = Object.keys(index)
390 await Promise.all(accts.map(a => keychain.remove(runner($), a)))
391 await $.store.set('index', {})
392 const gone = new Set(Object.values(index).map(x => x.name))
393 await update($, exported, names => names.filter(n => !gone.has(n)))
394 return { text: `Removed ${accts.length} stored ${accts.length === 1 ? 'secret' : 'secrets'} from the Keychain, every project.` }
395 }
396
397 // A name means this project's entry, else the global one.
398 const pick = (scope?: string) =>
399 Object.entries(index)
400 .filter(([, x]) => x.name === name && (scope ? x.scope === scope : x.scope === p.root || x.scope === GLOBAL))
401 .sort(([, a]) => (a.scope === p.root ? -1 : 1))[0]
402
403 if (verb === 'rm' && name) {
404 const hit = pick()
405 if (!hit) return { text: `${name} is not stored for this project. /secrets lists what is.` }
406 await keychain.remove(runner($), hit[0])
407 const { [hit[0]]: _, ...rest } = index
408 await $.store.set('index', rest)
409 await update($, exported, names => names.filter(n => n !== name))
410 return { text: `Removed ${name} (${label(hit[1].scope)}) from the Keychain.` }
411 }
412
413 if ((verb === 'global' || verb === 'local') && name) {
414 const hit = pick(verb === 'global' ? p.root : GLOBAL)
415 if (!hit) return { text: `${name} is not stored ${verb === 'global' ? 'for this project' : 'globally'}.` }
416 const value = (await read($, vault))[`secret:${name}`] ?? (await keychain.load(runner($), hit[0]))
417 if (value === undefined) return { text: `Could not read ${name} from the Keychain.` }
418 const scope = verb === 'global' ? GLOBAL : p.root
419 const acct = keychain.account(verb === 'global' ? GLOBAL : p.id, name)
420 await keychain.save(runner($), acct, value)
421 await keychain.remove(runner($), hit[0])
422 const { [hit[0]]: _, ...rest } = index
423 await $.store.set('index', { ...rest, [acct]: { ...hit[1], scope } })
424 return { text: verb === 'global' ? `${name} is now global: every project's sessions export it.` : `${name} now belongs to this project only.` }
425 }
426
427 return { text: 'Usage: /secrets, /secrets rm <NAME|all>, /secrets global <NAME>, /secrets local <NAME>' }
428 })
429}
430hooks/detect.ts 228 lines1// Finds secrets, and PII when asked, in a piece of text. Every quantifier is bounded, so a hostile row cannot stall a scan.
2import { envName, nameBefore } from './naming'
3import { PII } from './pii'
4
5export type Kind = 'secret' | 'pii'
6export type Hit = { start: number; end: number; value: string; rule: string; kind: Kind; name: string }
7export type Rule = {
8 id: string
9 kind: Kind
10 /** The env var a hit is stored under when the text around it names none */
11 name: string
12 re: RegExp
13 /** Capture group holding the value; 0 is the whole match */
14 group?: number
15 check?: (value: string) => boolean
16 /** Use a NAME= before the hit when there is one */
17 named?: boolean
18}
19
20// A token's edges: not glued to more token characters on either side.
21const L = '(?<![A-Za-z0-9_-])'
22const R = '(?![A-Za-z0-9_-])'
23
24// Prefix formats as each provider documents them. Order breaks ties between rules matching the same span.
25const PROVIDERS: Array<[id: string, name: string, pattern: string]> = [
26 ['anthropic', 'ANTHROPIC_API_KEY', 'sk-ant-[a-z]{2,8}\\d{2}-[A-Za-z0-9_-]{32,200}'],
27 ['openai', 'OPENAI_API_KEY', 'sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{8,200}T3BlbkFJ[A-Za-z0-9_-]{8,200}'],
28 ['openai-scoped', 'OPENAI_API_KEY', 'sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,250}'],
29 ['openrouter', 'OPENROUTER_API_KEY', 'sk-or-v1-[a-f0-9]{64}'],
30 ['elevenlabs', 'ELEVENLABS_API_KEY', 'sk_[a-f0-9]{48}'],
31 ['deepseek', 'DEEPSEEK_API_KEY', 'sk-[a-f0-9]{32}'],
32 ['aws-access-key', 'AWS_ACCESS_KEY_ID', '(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}'],
33 ['github', 'GITHUB_TOKEN', 'gh[pousr]_[A-Za-z0-9]{36,251}'],
34 ['github-fine-grained', 'GITHUB_TOKEN', 'github_pat_[A-Za-z0-9_]{82}'],
35 ['gitlab', 'GITLAB_TOKEN', 'gl(?:pat|dt|ptt|rt|oas)-[A-Za-z0-9_.-]{20,80}'],
36 ['slack', 'SLACK_TOKEN', 'xox[abposre]-[A-Za-z0-9-]{10,250}'],
37 ['slack-webhook', 'SLACK_WEBHOOK_URL', 'https://hooks\\.slack\\.com/(?:services|workflows|triggers)/[A-Za-z0-9_/-]{20,200}'],
38 ['stripe', 'STRIPE_SECRET_KEY', '(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,250}'],
39 ['stripe-webhook', 'STRIPE_WEBHOOK_SECRET', 'whsec_[A-Za-z0-9+/=]{32,100}'],
40 ['sendgrid', 'SENDGRID_API_KEY', 'SG\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]{43}'],
41 ['twilio', 'TWILIO_API_KEY', 'SK[a-f0-9]{32}'],
42 ['google', 'GOOGLE_API_KEY', 'AIza[A-Za-z0-9_-]{35}'],
43 ['google-oauth', 'GOOGLE_CLIENT_SECRET', 'GOCSPX-[A-Za-z0-9_-]{28}'],
44 ['supabase-pat', 'SUPABASE_ACCESS_TOKEN', 'sbp_[a-f0-9]{40}'],
45 ['supabase-secret', 'SUPABASE_SECRET_KEY', 'sb_secret_[A-Za-z0-9_-]{20,80}'],
46 ['posthog', 'POSTHOG_PERSONAL_API_KEY', 'phx_[A-Za-z0-9]{40,60}'],
47 ['xai', 'XAI_API_KEY', 'xai-[A-Za-z0-9]{70,90}'],
48 ['groq', 'GROQ_API_KEY', 'gsk_[A-Za-z0-9]{52}'],
49 ['replicate', 'REPLICATE_API_TOKEN', 'r8_[A-Za-z0-9]{37}'],
50 ['perplexity', 'PERPLEXITY_API_KEY', 'pplx-[A-Za-z0-9]{48}'],
51 ['huggingface', 'HF_TOKEN', 'hf_[A-Za-z]{34}'],
52 ['npm', 'NPM_TOKEN', 'npm_[A-Za-z0-9]{36}'],
53 ['pypi', 'PYPI_TOKEN', 'pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,300}'],
54 ['linear', 'LINEAR_API_KEY', 'lin_api_[A-Za-z0-9]{40}'],
55 ['notion', 'NOTION_TOKEN', '(?:ntn_[A-Za-z0-9]{40,50}|secret_[A-Za-z0-9]{43})'],
56 ['resend', 'RESEND_API_KEY', 're_[A-Za-z0-9]{8}_[A-Za-z0-9]{24}'],
57 ['shopify', 'SHOPIFY_ACCESS_TOKEN', 'shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}'],
58 ['databricks', 'DATABRICKS_TOKEN', 'dapi[a-f0-9]{32}(?:-\\d)?'],
59 ['sentry', 'SENTRY_AUTH_TOKEN', '(?:sntrys_[A-Za-z0-9+/=_-]{60,400}|sntryu_[a-f0-9]{64})'],
60 ['discord-bot', 'DISCORD_BOT_TOKEN', '[MN][A-Za-z0-9_-]{23,25}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,38}'],
61 ['discord-webhook', 'DISCORD_WEBHOOK_URL', 'https://(?:ptb\\.|canary\\.)?discord(?:app)?\\.com/api/webhooks/\\d{5,30}/[A-Za-z0-9_-]{50,100}'],
62 ['telegram', 'TELEGRAM_BOT_TOKEN', '\\d{8,10}:AA[A-Za-z0-9_-]{33}'],
63 ['mailgun', 'MAILGUN_API_KEY', 'key-[a-f0-9]{32}'],
64 ['mailchimp', 'MAILCHIMP_API_KEY', '[a-f0-9]{32}-us\\d{1,2}'],
65 ['doppler', 'DOPPLER_TOKEN', 'dp\\.(?:pt|st|sa|ct|scim|audit)\\.[A-Za-z0-9]{40,44}'],
66 ['digitalocean', 'DIGITALOCEAN_TOKEN', 'do[opr]_v1_[a-f0-9]{64}'],
67 ['mapbox', 'MAPBOX_SECRET_TOKEN', 'sk\\.eyJ[A-Za-z0-9_-]{30,300}\\.[A-Za-z0-9_-]{20,30}'],
68 ['fireworks', 'FIREWORKS_API_KEY', 'fw_[A-Za-z0-9]{24}'],
69 ['figma', 'FIGMA_TOKEN', 'figd_[A-Za-z0-9_-]{40,60}'],
70 ['atlassian', 'ATLASSIAN_API_TOKEN', 'ATATT3[A-Za-z0-9_=-]{150,250}'],
71 ['square', 'SQUARE_ACCESS_TOKEN', '(?:EAAA[A-Za-z0-9_-]{60}|sq0csp-[A-Za-z0-9_-]{43})'],
72 ['age', 'AGE_SECRET_KEY', 'AGE-SECRET-KEY-1[0-9A-Z]{58}'],
73]
74
75// Passwords people leave in local connection strings on purpose; hiding them only gets in the way.
76const DEV_PASSWORDS = new Set(['password', 'pass', 'postgres', 'root', 'admin', 'secret', 'test', 'example', 'changeme', 'mysql', 'guest'])
77
78const urlPassword = (url: string): boolean => {
79 const m = /^[^:]+:\/\/([^:/@]*):([^@]*)@/.exec(url)
80 const [user, pass] = [m?.[1] ?? '', m?.[2] ?? '']
81 return (
82 pass.length >= 6 &&
83 pass !== user &&
84 (/\d/.test(pass) || pass.length >= 16) &&
85 !/pass(?:word)?/i.test(pass) &&
86 !DEV_PASSWORDS.has(pass.toLowerCase()) &&
87 !isTemplate(pass)
88 )
89}
90
91/**
92 * A value with no prefix to go on must look generated: a letter and a digit, 3+ bits of entropy per character,
93 * not a reference or a template. Constants (`MF-Proxy-Shared-Secret`), member access (`this.authStorage`) and
94 * doc samples have no digit; generated keys almost always do.
95 */
96export const looksSecret = (v: string) =>
97 /\d/.test(v) &&
98 /[A-Za-z]/.test(v) &&
99 // short test values (`token-1a`, `abcd1234`) stay; a short real password mixes cases
100 (v.length >= 12 || (/[a-z]/.test(v) && /[A-Z]/.test(v))) &&
101 !isTemplate(v) &&
102 !wordy(v) &&
103 entropy(v) >= 3
104
105/**
106 * Made of words, so a name rather than a key: `EthereumWeb3Credentials`, `x-d1-session-commit-token`.
107 * Words are 3+ letter runs with a vowel; a generated key splits into short, mostly vowel-less runs.
108 */
109export function wordy(v: string): boolean {
110 const runs = v.split(/[^A-Za-z]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean)
111 const letters = runs.reduce((n, r) => n + r.length, 0)
112 const words = runs.filter(r => r.length >= 3 && /[aeiouy]/i.test(r))
113 return words.length >= 2 && words.reduce((n, r) => n + r.length, 0) >= 0.7 * letters
114}
115
116export const RULES: Rule[] = [
117 ...PROVIDERS.map(([id, name, pattern]): Rule => ({ id, kind: 'secret', name, re: new RegExp(`${L}${pattern}${R}`, 'g'), named: true })),
118 {
119 id: 'private-key',
120 kind: 'secret',
121 name: 'PRIVATE_KEY',
122 re: /-----BEGIN ((?:[A-Z0-9]{1,12} ){0,3})PRIVATE KEY(?: BLOCK)?-----[\s\S]{20,65536}?-----END \1PRIVATE KEY(?: BLOCK)?-----/g,
123 named: true,
124 },
125 {
126 // A key cut off before its END line (head of a file, a truncated log) still leaks its body.
127 id: 'private-key-partial',
128 kind: 'secret',
129 name: 'PRIVATE_KEY',
130 re: /-----BEGIN (?:[A-Z0-9]{1,12} ){0,3}PRIVATE KEY(?: BLOCK)?-----(?:\r?\n|\\n)[A-Za-z0-9+/=\r\n\\]{40,65536}/g,
131 named: true,
132 },
133 { id: 'jwt', kind: 'secret', name: 'JWT', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,2000}\.eyJ[A-Za-z0-9_-]{8,8000}\.[A-Za-z0-9_-]{16,2000}(?![A-Za-z0-9_-])/g, named: true },
134 {
135 id: 'url-credentials',
136 kind: 'secret',
137 name: 'DATABASE_URL',
138 re: /(?<![A-Za-z0-9+.-])[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/@'"`<>]{1,256}:[^\s@/'"`<>]{1,256}@[^\s'"`<>]{1,2048}/g,
139 check: urlPassword,
140 named: true,
141 },
142 {
143 id: 'auth-header',
144 kind: 'secret',
145 name: 'AUTH_TOKEN',
146 re: /(?:proxy-)?authorization["']?\s{0,4}[:=]\s{0,4}["']?(?:bearer|basic|token)\s{1,4}([A-Za-z0-9._~+/=-]{16,4096})/gi,
147 group: 1,
148 check: looksSecret,
149 },
150 {
151 id: 'api-key-header',
152 kind: 'secret',
153 name: 'API_KEY',
154 re: /(?:x-api-key|xi-api-key|api-key|apikey|x-auth-token)["']?\s{0,4}:\s{0,4}["']?([A-Za-z0-9._~+/=-]{16,1024})/gi,
155 group: 1,
156 check: looksSecret,
157 },
158]
159
160const SECRET_NAME = /(?:^|_)(?:API_?KEY|SECRET|SECRETS|TOKEN|PASSWORD|PASSWD|PWD|PASS|CREDENTIALS?|PRIVATE_?KEY|ACCESS_?KEY|AUTH_?KEY|AUTH|CLIENT_?SECRET|SIGNING_?KEY|ENCRYPTION_?KEY|MASTER_?KEY|SERVICE_?ROLE(?:_?KEY)?)(?:_|$)/
161const ASSIGNMENT = /(?<![A-Za-z0-9_.-])(?:export\s{1,4})?(["']?)([A-Za-z_][A-Za-z0-9_.-]{0,80})\1\s{0,4}(?::=|=>|:|=)\s{0,4}(["'`]?)([^\s"'`]{8,512})\3/g
162
163/** A value that stands for a secret instead of being one: a reference, a template slot, a sample. */
164export function isTemplate(v: string): boolean {
165 return (
166 /^(?:\$|%|\{\{|<|@|~|\.{0,2}\/|process\.env|os\.environ|env\(|import\.meta|https?:)/i.test(v) ||
167 /\[(?:secret|pii):/.test(v) ||
168 /^(?:true|false|null|none|undefined|nil|string|number|boolean|required|optional)[;,]?$/i.test(v) ||
169 /^(.)\1*$/.test(v) ||
170 /x{4,}|\*{3,}|\.{3}|<|>|\$\{|changeme|placeholder|example|dummy|sample|redacted|passw(?:or)?d|secret|test|fake|mock|demo|your[-_]?(?:api[-_]?)?(?:key|token)/i.test(v) ||
171 // code, not a value: a member access, a call, an index
172 /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+[;,]?$/.test(v) ||
173 /[()[\]{};]/.test(v)
174 )
175}
176
177/** Shannon entropy in bits per character. */
178export function entropy(v: string): number {
179 const counts = new Map<string, number>()
180 for (const c of v) counts.set(c, (counts.get(c) ?? 0) + 1)
181 let bits = 0
182 for (const n of counts.values()) bits -= (n / v.length) * Math.log2(n / v.length)
183 return bits
184}
185
186/** A bare value assigned to a name that says it is secret: `OPENAI_KEY=…`, `password: "…"`, `apiKey = '…'`. */
187function assignments(text: string): Hit[] {
188 const hits: Hit[] = []
189 for (const m of text.matchAll(ASSIGNMENT)) {
190 const name = envName(m[2]!)
191 const value = m[4]!
192 // Unquoted, it is an env file, YAML or a shell line: their values don't carry code punctuation (`,` `$` `|` `&` `?`, brackets), minified JS does.
193 if (!m[3] && (/[,$|&?(){}[\];<>\\]/.test(value) || value.startsWith('!'))) continue
194 if (!SECRET_NAME.test(name) || !looksSecret(value)) continue
195 const start = m.index! + m[0].length - value.length - m[3]!.length
196 hits.push({ start, end: start + value.length, value, rule: 'assignment', kind: 'secret', name })
197 }
198 return hits
199}
200
201export type FindOptions = { pii?: boolean; off?: ReadonlySet<string> }
202
203/** Every secret (and PII when asked) in `text`, sorted, overlaps resolved: leftmost, then longest, then rule order. */
204export function find(text: string, opts: FindOptions = {}): Hit[] {
205 const rules = [...RULES, ...(opts.pii ? PII : [])].filter(r => !opts.off?.has(r.id))
206 const all: Array<Hit & { order: number }> = []
207 rules.forEach((rule, order) => {
208 for (const m of text.matchAll(rule.re)) {
209 const value = m[rule.group ?? 0]
210 if (!value || (rule.check && !rule.check(value))) continue
211 const start = m.index! + (rule.group ? m[0].lastIndexOf(value) : 0)
212 const before = rule.named && rule.kind === 'secret' ? nameBefore(text, start) : undefined
213 all.push({ start, end: start + value.length, value, rule: rule.id, kind: rule.kind, name: before ?? rule.name, order })
214 }
215 })
216 if (!opts.off?.has('assignment')) all.push(...assignments(text).map(h => ({ ...h, order: rules.length })))
217
218 all.sort((a, b) => a.start - b.start || b.end - a.end || a.order - b.order)
219 const kept: Hit[] = []
220 let reach = -1
221 for (const { order: _, ...h } of all) {
222 if (h.start < reach) continue
223 kept.push(h)
224 reach = h.end
225 }
226 return kept
227}
228hooks/keychain.ts 35 lines1// The login Keychain through /usr/bin/security. Values travel on stdin as hex, never in argv, and hex needs no quoting in `security -i`.
2import type { EngineInterface } from 'claude-code'
3
4export const SERVICE = 'claude-secret-keeper'
5
6type Run = EngineInterface['process']['run']
7
8export const toHex = (s: string) => [...new TextEncoder().encode(s)].map(b => b.toString(16).padStart(2, '0')).join('')
9
10export function fromHex(h: string): string | undefined {
11 if (!/^(?:[0-9a-f]{2})+$/.test(h)) return undefined
12 return new TextDecoder().decode(Uint8Array.from(h.match(/../g)!, b => parseInt(b, 16)))
13}
14
15/** `<scope>:<NAME>`, where scope is `global` or a hash of the project root: no spaces, so `security -i` reads it as one word. */
16export const account = (scope: string, name: string) => `${scope}:${name}`
17
18export async function save(run: Run, acct: string, value: string): Promise<void> {
19 const r = await run(['security', '-i'], {
20 stdin: `add-generic-password -U -s ${SERVICE} -a ${acct} -l ${SERVICE} -w ${toHex(value)}\n`,
21 timeoutMs: 10_000,
22 })
23 if (r.exitCode !== 0 || r.stderr.trim()) throw new Error(r.stderr.trim() || `security exited ${r.exitCode}`)
24}
25
26export async function load(run: Run, acct: string): Promise<string | undefined> {
27 const r = await run(['security', 'find-generic-password', '-s', SERVICE, '-a', acct, '-w'], { timeoutMs: 10_000 })
28 return r.exitCode === 0 ? fromHex(r.stdout.trim()) : undefined
29}
30
31export async function remove(run: Run, acct: string): Promise<boolean> {
32 const r = await run(['security', 'delete-generic-password', '-s', SERVICE, '-a', acct], { timeoutMs: 10_000 })
33 return r.exitCode === 0
34}
35hooks/shell.ts 21 lines1// How a Bash command gets the secrets: through the environment, never its text. Hooks beneath a mod and the
2// permission check see the command as rewritten, and a hook may log it; the text only names slot variables.
3
4export const TOKEN = /\[(secret:[A-Z_][A-Z0-9_]*|pii:[A-Z_]+#\d+)\]/g
5
6/** How many secrets one command can get: `$.env.set` takes literal names, so the slots are spelled out. */
7export const SLOTS = 16
8
9/** `export A="$SECRET_KEEPER_1" B="$SECRET_KEEPER_2"; `: plain exports of references, which permission rules accept. */
10export const prefixFor = (names: readonly string[]) =>
11 names.length ? `export ${names.map((n, i) => `${n}="$SECRET_KEEPER_${i + 1}"`).join(' ')}; ` : ''
12
13/**
14 * `[secret:NAME]` written into a command becomes `"${NAME}"`.
15 * ponytail: inside single quotes that reads as literal text; the note tells Claude to write $NAME, which has the same limit.
16 */
17export const refsToVars = (command: string) => command.replace(/\[secret:([A-Z_][A-Z0-9_]*)\]/g, '"${$1}"')
18
19/** The placeholders a piece of text carries, as `secret:NAME` / `pii:LABEL#n` keys. */
20export const tokensIn = (text: string) => [...text.matchAll(TOKEN)].map(m => m[1]!)
21hooks/naming.ts 22 lines1// What env var a secret is kept under: the name it was pasted with, else its provider's usual one.
2
3/** `apiKey`, `api-key`, `db.password` → `API_KEY`, `API_KEY`, `DB_PASSWORD`. */
4export function envName(raw: string): string {
5 const snake = raw
6 .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
7 .replace(/[^A-Za-z0-9]+/g, '_')
8 .replace(/^_+|_+$/g, '')
9 .toUpperCase()
10 return /^[A-Z_]/.test(snake) ? snake : `_${snake}`
11}
12
13// `NAME=`, `export NAME=`, `NAME: `, `"NAME": "`, `$env:NAME = '` right before the value, on the same line.
14const BEFORE = /(?:^|[^A-Za-z0-9_.-])(?:export\s{1,4}|set\s{1,4}|\$env:)?["']?([A-Za-z_][A-Za-z0-9_]{0,80})["']?\s{0,4}(?::=|=>|:|=)\s{0,4}["'`]?$/
15
16/** The env-style (UPPER_SNAKE) name assigned the value starting at `at`, if the text gives one. */
17export function nameBefore(text: string, at: number): string | undefined {
18 const from = Math.max(text.lastIndexOf('\n', at - 1) + 1, at - 160)
19 const raw = BEFORE.exec(text.slice(from, at))?.[1]
20 return raw && /^[A-Z][A-Z0-9_]*$/.test(raw) ? raw : undefined
21}
22hooks/pii.ts 53 lines1// Personal data, hidden only when the `pii` option is on. Numbers with a check digit must pass it.
2import type { Rule } from './detect'
3
4const digits = (v: string) => v.replace(/\D/g, '').split('').map(Number)
5
6export function luhn(v: string): boolean {
7 const d = digits(v).reverse()
8 const sum = d.reduce((s, n, i) => s + (i % 2 ? (n * 2 > 9 ? n * 2 - 9 : n * 2) : n), 0)
9 return d.length >= 13 && sum % 10 === 0
10}
11
12export function iban(v: string): boolean {
13 const s = v.replace(/\s/g, '')
14 const moved = (s.slice(4) + s.slice(0, 4)).replace(/[A-Z]/g, c => String(c.charCodeAt(0) - 55))
15 let rest = 0
16 for (const c of moved) rest = (rest * 10 + Number(c)) % 97
17 return rest === 1
18}
19
20/** Australian tax file number: weighted sum divisible by 11. */
21export function tfn(v: string): boolean {
22 const d = digits(v)
23 const w = [1, 4, 3, 7, 5, 8, 6, 9, 10]
24 return d.length === 9 && d.reduce((s, n, i) => s + n * w[i]!, 0) % 11 === 0
25}
26
27/** Medicare card: the ninth digit checks the first eight. */
28export function medicare(v: string): boolean {
29 const d = digits(v)
30 const w = [1, 3, 7, 9, 1, 3, 7, 9]
31 return d.length >= 10 && d[0]! >= 2 && d[0]! <= 6 && w.reduce((s, x, i) => s + x * d[i]!, 0) % 10 === d[8]
32}
33
34/** Australian business number: first digit minus one, weighted sum divisible by 89. */
35export function abn(v: string): boolean {
36 const d = digits(v)
37 const w = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
38 if (d.length !== 11) return false
39 d[0]! -= 1
40 return d.reduce((s, n, i) => s + n * w[i]!, 0) % 89 === 0
41}
42
43export const PII: Rule[] = [
44 { id: 'email', kind: 'pii', name: 'EMAIL', re: /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,8}\.[A-Za-z]{2,24}(?![A-Za-z0-9-])/g },
45 { id: 'card', kind: 'pii', name: 'CARD', re: /(?<!\d)\d(?:[ -]?\d){12,18}(?!\d)/g, check: luhn },
46 { id: 'iban', kind: 'pii', name: 'IBAN', re: /(?<![A-Z0-9])[A-Z]{2}\d{2}(?: ?[A-Z0-9]){11,30}(?![A-Z0-9])/g, check: iban },
47 { id: 'au-tfn', kind: 'pii', name: 'TFN', re: /(?<!\d)\d{3}[ -]?\d{3}[ -]?\d{3}(?!\d)/g, check: tfn },
48 { id: 'au-medicare', kind: 'pii', name: 'MEDICARE', re: /(?<!\d)[2-6]\d{3}[ -]?\d{5}[ -]?\d(?:[ -]?\d)?(?!\d)/g, check: medicare },
49 { id: 'au-abn', kind: 'pii', name: 'ABN', re: /(?<!\d)\d{2} ?\d{3} ?\d{3} ?\d{3}(?!\d)/g, check: abn },
50 { id: 'us-ssn', kind: 'pii', name: 'SSN', re: /(?<!\d)(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}(?!\d)/g },
51 { id: 'phone', kind: 'pii', name: 'PHONE', re: /(?<![\w+])(?:\+\d{1,3}[ -]?(?:\(\d{1,4}\)[ -]?)?\d{2,4}(?:[ -]?\d{2,4}){1,3}|\(?0[2-478]\)?[ -]?\d{4}[ -]?\d{4}|04\d{2}[ -]?\d{3}[ -]?\d{3})(?![\w])/g },
52]
53types/index.d.ts 19 lines1/** What the index in `$.store` keeps per Keychain account: never the value. */
2export type StoredEntry = {
3 name: string
4 /** `global`, or the root of the project it was pasted in */
5 scope: string
6 /** Local date it was saved, YYYY-MM-DD */
7 saved: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'secret-keeper': {
13 vault: Record<string, string>
14 exported: string[]
15 project: { id: string; root: string }
16 }
17 }
18}
19