SLOPSHOPPER

secret-keeper

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…

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-keeper
› 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 › /secrets ⎿ secret-keeper: Nothing stored for this project. Paste a key (NAME=value works best) and it is kept for this project. ⎿ secret-keeper: Hidden this session: 2 found in files (not stored), 0 personal. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-keeper

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.

Install

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

How it works

  • Pasting. Each secret in a prompt becomes [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.
  • Per project. A stored secret belongs to the project you pasted it in: the git root, else the working directory. Sessions in that project load it; sessions in other projects don't. /secrets global NAME shares one with every project.
  • Bash. Values travel in the environment, never in the command text.
  • While a Bash call runs, the process variables SECRET_KEEPER_1 to SECRET_KEEPER_16 hold this project's secrets.
  • The command gets a prefix of plain exports that name those slots, such as export GITHUB_TOKEN="$SECRET_KEEPER_1" NPM_TOKEN="$SECRET_KEEPER_2";.
  • Why: the hooks beneath a mod and the permission check see the command as rewritten. Some hooks log it, and the auto-mode classifier reads it. None of them ever see a value.
  • Allow rules such as Bash(git push:*) still match, because the prefix is ordinary exports.
  • The transcript keeps the command as Claude wrote it, with $NAME.
  • A [secret:NAME] Claude writes into a command becomes "${NAME}".
  • If an environment write is refused, Bash runs without the secrets. They are never put in the command instead.
  • Files. In 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.
  • Everything Claude reads. Tool output, file contents, Claude's own text and notices are scanned before they're stored or sent:
  • a new secret found there is hidden for the session, but not stored or exported;
  • a value it already knows is hidden again wherever it shows up: raw, JSON-escaped, base64, base64url or hex.
  • The standing note. One paragraph in the environment section tells Claude:
  • how placeholders work;
  • that pasting a key is expected and needs no warning;
  • that a secret it does see in plain text was missed and is worth saying so once;
  • which names Bash has.
  • Failing closed. If a scan fails, the row is withheld, the prompt is not sent, or the tool call is refused. Nothing gets through unscanned.

Commands

/secretsWhat's stored for this project and globally, plus a count for other projects and what this session hid
/secrets rm NAMERemove one (this project's first, then the global one)
/secrets rm allRemove every secret secret-keeper stored, in every project
/secrets global NAMEShare this project's secret with every project
/secrets local NAMEMake a global secret this project's only

Options

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:
  • email addresses and phone numbers;
  • payment cards (Luhn) and IBANs (mod-97);
  • Australian TFN, Medicare and ABN numbers;
  • US SSNs.

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.

Rules

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:

  • a letter and a digit;
  • 12 or more characters, or 8 or more mixing upper and lower case;
  • at least 3 bits of entropy per character;
  • not mostly made of words (that's a name, like EthereumWeb3Credentials);
  • not a reference or a sample ($VAR, process.env.X, changeme, your-api-key, test, …);
  • unquoted, no code punctuation (that's minified JavaScript).

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:

  • The provider rules: no hits on ordinary code.
  • All the rules together: 23 hits, and every one is a credential-shaped test fixture or a documentation example: sample PEM keys, JWT test vectors, UUID access tokens in fixtures. Hiding those costs nothing, since an Edit still restores them.

What it can reach

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

Limits

  • It doesn't stop a value leaving the machine. Bash has your stored secrets in its environment, so an instruction hidden in a page or file Claude reads could have it send one out (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.
  • During a Bash call, what Claude Code starts inherits the slot variables. That's the command itself, the settings hooks that run for it, and anything else started in that window. Between calls they're unset. A hook that logs commands sees only the export NAME="$SECRET_KEEPER_n" prefix.
  • 16 secrets per project at most reach Bash. They're taken in name order, and the rest are left out with one log line.
  • Hooks on Edit, Write and NotebookEdit see restored values, because the value has to reach the tool for the file to get it.
  • Other programs running as you can read the items. The items trust /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.
  • macOS only for storage. Elsewhere, saving fails with one log line and pasted secrets last the session.
  • A secret with no known shape and no secret-sounding name stays visible: a bare 40-character token pasted on its own, for example. Paste it as NAME=value and the name carries it. When Claude does see one in plain text, the note tells it to say so once.
  • The model's own tool-call blocks aren't rewritable, and neither are the tool records the engine keeps beside the conversation. A Write record keeps the file as written, with the value in it.
  • A slash command's arguments aren't scanned (prompt.submit doesn't rewrite commands). Paste keys as a normal message.
  • Single quotes. [secret:NAME] inside single quotes in a shell command stays literal, as $NAME would.
  • Images aren't scanned.

Uninstall

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.

Source 7 files
hooks/register.ts 430 lines
1import { 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}
430
hooks/detect.ts 228 lines
1// 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}
228
hooks/keychain.ts 35 lines
1// 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}
35
hooks/shell.ts 21 lines
1// 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]!)
21
hooks/naming.ts 22 lines
1// 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}
22
hooks/pii.ts 53 lines
1// 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]
53
types/index.d.ts 19 lines
1/** 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