SLOPSHOPPER

secret-box

Claude can use a secret without seeing it: a masked box saves keys to the macOS Keychain or Windows Credential Manager (or .env) and gives Claude a…

newpanebandrowsguardtoast
v0.1.0MITupdated 2026-10-19ApisMellow/secret-box
A shopper browsing a rack in a slop shop
README

secret-box

A Claude Code mod that lets Claude use a secret without seeing it. Keys are saved to the macOS Keychain or Windows Credential Manager (or .env), Claude works with placeholders, and any key a tool prints comes back hidden.

Install

claude plugin marketplace add ApisMellow/secret-box
claude plugin install secret-box@secret-box

Credits

secret-box began as a mod in claude-code-mods by Ray Amjad (MIT). The detector, the masked box and the original design are his. This project starts from that code and goes its own way, beginning with the macOS Keychain and Windows Credential Manager backends.

License

MIT. See LICENSE: copyright Ray Amjad and David Whitlock.

TODO

Full documentation is being written.

Source 5 files
hooks/register.jsx 1118 lines
1// secret-box: Claude can use a secret without ever seeing it.
2//
3// Pattern: guard what you can't undo. A secret in the conversation is in the
4// transcript for good, so it must never get there:
5//   - In: Claude calls request_secret, you paste the value into a masked box,
6//     the mod saves it to the macOS Keychain (or .env) and hands Claude a
7//     placeholder. use_secret loads a Keychain item as a placeholder too.
8//   - Out: every secret a tool would show Claude (a `cat .env`, an API answer,
9//     a key you paste) is swapped for a placeholder like [REDACTED-SECRET-1a2b3c4d].
10//   - Use: a placeholder in a tool's input becomes the real value as the tool
11//     runs, so `curl -H "Authorization: Bearer [REDACTED-SECRET-...]"` works.
12//   - Never: committing .env is refused, since a pushed key can't be taken back.
13//
14// The redaction is transcript-redactor (./redact.ts, ./detect.ts): its hooks
15// do the In, Out and Use. redact.ts adds one thing: a call it filled with real
16// values gets its permission check answered on the call as Claude wrote it
17// (ownApproval), so the key never reaches the dialog or the auto-mode
18// classifier. This file adds the box, use_secret,
19// list_key_names and migrate_env_to_keychain, keeps .env pointers readable,
20// teaches the vault the values in .env and the Keychain items a command (or
21// bin/keychain-env) reads, and guards commits. Nothing is written but Keychain
22// items and .env.
23
24import { register as redactor, LIVE_TAG_TOOLS, brand, errorText, tagNames } from './redact.ts'
25import { collect, keepSecret, makeConfig, restore, scrubSecrets, walk } from './detect.ts'
26import {
27  addHint, badName, BOX_COMMENT, boxBackend, defaultAccount, envAssignments, envPointers, envValue, keychainItems, keychainReads,
28  itemExists, looksLikeKey, MIN_HIDDEN, NOT_FOUND, parsePointer, platformOf, pointerTo, quoteCloses, readCommand, readKeychain, saveToKeychain,
29  serviceRead, SHELL_ACTIVE,
30} from './keychain.js'
31import { bashPath, credAddHint, credExists, credmanItems, credRead, credReadCommand, readCredman, saveToCredman } from './credman.js'
32
33const TOOL = 'mcp__secret-box__request_secret'
34const USE = 'mcp__secret-box__use_secret'
35const LIST = 'mcp__secret-box__list_key_names'
36const MIGRATE = 'mcp__secret-box__migrate_env_to_keychain'
37// list_key_names says this first, word for word, and so do the README and the skill
38const NAMES_ONLY = "Returns key names only and never fetches or returns any key's value."
39const PANE = 'secret-box'
40const DOT = '•'
41
42// The one request being collected right now. The real value lives only here,
43// in the mod's memory, until it is saved.
44let pending = null // { name, reason, value, answer, isInBand }
45
46// A .env key whose name says it holds a secret
47const SECRET_NAME = /KEY|TOKEN|SECRET|PASS|PWD|CREDENTIAL|AUTH|PRIVATE|DSN|SALT|SIGNING|WEBHOOK/i
48const ASSIGNMENT = /^(\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*)(.*)$/gm
49
50// A Keychain read in a Bash command whose output the pre-load can't promise to
51// hide: it can't be parsed, it names something only the shell knows, the fetch
52// failed, the value is too short to hide, or it dumps the keychain. Every such
53// case comes here, and `refuse` is the one switch: the refuseUnmaskable
54// option, off by default. Off, the command runs and only the detector scans
55// what it prints. On, the command is refused.
56export function unmaskable(why, refuse) {
57  return refuse
58    ? { deny: `secret-box: this command reads the Keychain, but ${why}, so what it prints can't be hidden. Use use_secret instead.` }
59    : undefined
60}
61
62// 'mac', 'windows', 'other' or 'unknown', asked once per load (`memo` is that load's)
63function platformIn($, memo) {
64  return (memo.platform ??= platformOf($))
65}
66
67// The system's vault, by platform: the macOS Keychain, or Windows Credential
68// Manager by way of bin/credman.ps1. Both hold items by service and account,
69// and the rest of this file reads and writes through these alone. Off both,
70// the Keychain's (use_secret and the rest aren't registered there, and on an
71// unknown platform a Mac is what they may still find).
72function vaultOf($, platform) {
73  const root = $.plugin.root
74  if (platform === 'windows') {
75    return {
76      name: 'Windows Credential Manager',
77      short: 'Credential Manager',
78      the: 'Credential Manager',
79      The: 'Credential Manager',
80      inBash: ' in the Bash tool (Git Bash)',
81      reader: 'credman.ps1',
82      serviceRead: credRead,
83      read: (read) => readCredman($, root, read),
84      save: (service, account, value) => saveToCredman($, root, service, account, value),
85      exists: (pointer) => credExists($, root, pointer),
86      items: () => credmanItems($, root),
87      account: async (options) => String((options && options.keychainAccount) || '').trim() || (await $.env.get('USERNAME')) || '',
88      addHint: (service, account) => credAddHint(root, service, account),
89      readCommand: (service, account) => credReadCommand(root, service, account),
90      tagHint: credAddHint(root, 'SERVICE', 'ACCOUNT'),
91      directRead: `${bashPath(root)}/bin/credman get`,
92      failed: (got) => `credman.ps1 could not read it (exit ${got.exitCode}${got.why ? `: ${got.why}` : ''})`,
93      unreadable: 'Ask the user to check the item in Credential Manager (Control Panel, Credential Manager, Windows Credentials)',
94    }
95  }
96  return {
97    name: 'the macOS Keychain',
98    short: 'Keychain',
99    the: 'the Keychain',
100    The: 'The Keychain',
101    inBash: '',
102    reader: 'security',
103    serviceRead,
104    read: (read) => readKeychain($, read),
105    save: (service, account, value) => saveToKeychain($, service, account, value),
106    exists: (pointer) => itemExists($, pointer),
107    items: () => keychainItems($),
108    account: (options) => defaultAccount($, options),
109    addHint,
110    readCommand,
111    tagHint: 'security add-generic-password -U -s SERVICE -a ACCOUNT -j secret-box -w',
112    directRead: '`security find-generic-password`',
113    failed: (got) => `security exited ${got.exitCode}`,
114    unreadable: 'the keychain may be locked, or access to the item was refused. Ask the user to unlock it (security unlock-keychain) or to allow access when macOS asks',
115  }
116}
117
118// How Claude is to use a Keychain value it now has a placeholder for, by what
119// the value holds, in two parts. `loaded(head)` ends the one line the user
120// sees in the tool's row: what to write in a command (the placeholder, or a
121// read of the item inside the command), so the model has it even if the
122// context is lost. `guide` is the rest, for the model alone (the answer's
123// context). The redactor won't put a value with a space, a quote or another
124// shell character into a Bash command. So such a value gets no placeholder:
125// Claude reads it from the Keychain inside the command, or not at all.
126function howToUse(vault, service, account, value, tag) {
127  const never = "Never write the value into a file: Claude Code keeps a plain copy of every file it edits under ~/.claude/file-history."
128  const short = value.length < MIN_HIDDEN ? ` It is under ${MIN_HIDDEN} characters, so if a command prints it, it can't be hidden: don't print it.` : ''
129  if (!SHELL_ACTIVE.test(value)) {
130    return {
131      loaded: (head) => `${head} as ${tag}.`,
132      guide: `You can't see the value. To use it, write ${tag} in a Bash command: it becomes the real value when the command runs and is hidden again in the output.${short} ${never}`,
133    }
134  }
135  if (/^[\x20-\x7e]*$/.test(value)) {
136    // Pasted with its own quotes into a string already in double quotes, the
137    // quotes would cancel and the shell would split the value into fragments
138    const quoted = vault.readCommand(service, account)
139    const bare = quoted.slice(1, -1)
140    return {
141      loaded: (head) => `${head}. Use it in Bash as ${quoted}.`,
142      guide: `You can't see the value. It holds a space or a shell character, so it can't go into a command as a placeholder. Instead, have the shell read it from ${vault.name} where the value goes. As a whole argument of its own, write exactly: ${quoted}. Inside a string that is already in double quotes, write it without the outer quotes, so it stays one word inside that string: -H "Authorization: Bearer ${bare}". The value never appears in the command, and anything the command prints of it comes back hidden.${short} ${never}`,
143    }
144  }
145  return {
146    loaded: (head) => `${head}, but its value can't go into a Bash command.`,
147    guide: `You can't see the value. It holds characters that can't safely go into a Bash command (a newline, say), so it can't be used here. ${never} Ask the user to store it base64-encoded instead, as the secret-box README describes.`,
148  }
149}
150
151// bin/keychain-env, by the absolute path Claude can run it from (C:/x/y on
152// Windows, as Git Bash takes it)
153function runnerPath($) {
154  return `${bashPath($.plugin.root)}/bin/keychain-env`
155}
156
157
158// Every value this file hands the vault. The vault can't be asked what it
159// holds, and values under 8 characters aren't indexed for the scrubber to
160// find, so isCleanPointer checks against this set too. `name` says what the
161// value is (a Keychain item, a .env variable) in the approval prompt.
162const keptValues = new Set()
163function keep(value, name) {
164  keptValues.add(value)
165  const tag = keepSecret(value)
166  if (name) tagNames.set(tag, name)
167  return tag
168}
169
170// How the approval prompt names an item of the vault
171function itemName(vault, service, account) {
172  return `the ${vault.short} item ${service}${account ? ` (account ${account})` : ''}`
173}
174
175// A pointer that is only a pointer: its service and account each pass the
176// detector on their own, aren't shaped like a key (hex, a UUID, a long mixed
177// token, which the detector leaves alone), and hold no placeholder and no
178// value this mod hid. The text a hidden span stands for has any key inside it
179// put back, so a real key written after `keychain:` reads back as
180// `keychain:<key>`. It stays hidden.
181function isCleanPointer(value, cfg) {
182  const pointer = parsePointer(value)
183  if (!pointer) return false
184  return [pointer.service, pointer.account ?? ''].every(
185    (s) =>
186      !s.includes('[REDACTED-') &&
187      !looksLikeKey(s) &&
188      ![...keptValues].some((v) => v.length >= 4 && s.includes(v)) &&
189      collect(() => scrubSecrets(s, cfg)).tags.size === 0,
190  )
191}
192
193// The detector takes the value of `OPENAI_API_KEY=keychain:openai` for a
194// password and hides it. A pointer only names a Keychain item, so a
195// placeholder that stands for a clean one is put back as the pointer text.
196const SECRET_TAG = /\[REDACTED-SECRET-[0-9a-f]{8}\]/g
197function showPointers(r, cfg) {
198  let changed = false
199  const show = (text) =>
200    typeof text !== 'string' || !text.includes('[REDACTED-SECRET-')
201      ? text
202      : text.replace(SECRET_TAG, (tag) => {
203          const value = restore(tag)
204          if (value === tag || !isCleanPointer(value, cfg)) return tag
205          changed = true
206          return value
207        })
208  if (r.deny !== undefined) {
209    const deny = show(r.deny)
210    return changed ? { deny } : r
211  }
212  const st = { incomplete: false }
213  const result = walk(r.result, show, st)
214  const text = show(r.text)
215  const context = r.context?.map(show)
216  if (!changed || st.incomplete) return r
217  // An errored answer can't be rewritten in place (see the redactor's
218  // tool.call): it goes back as a refusal holding the new text
219  if (r.isError) return { deny: errorText(text, result) }
220  return { result, ...(context ? { context } : {}) }
221}
222
223// A .env file with a key written in it in plaintext
224const holdsPlaintext = (text) => envAssignments(text).some(([name, value]) => SECRET_NAME.test(name) && value !== '' && !parsePointer(value))
225
226// The one note per session, at the first sign of keys in play, that tells the
227// model secret-box is here and how .env pointers work
228async function nudgeText($, vault, platform) {
229  let plaintext = []
230  try {
231    plaintext = (await projectEnvFiles($)).filter((f) => holdsPlaintext(f.text)).map((f) => f.name)
232  } catch {}
233  return (
234    `secret-box is installed: keys live in ${vault.name}, never in a file or the chat. In a .env file, write a pointer ` +
235    'instead of a value, NAME=keychain:SERVICE or NAME=keychain:SERVICE?account=ACCOUNT, and run the app with ' +
236    `${runnerPath($)} -- COMMAND${platform === 'windows' ? ' in the Bash tool (Git Bash); a placeholder works in Bash, not in PowerShell' : ''}. ` +
237    'Load the secret-box:secrets skill before working with any key or .env file.' +
238    (plaintext.length ? ` ${plaintext.join(', ')} hold${plaintext.length === 1 ? 's' : ''} plaintext keys: offer to move them with migrate_env_to_keychain.` : '')
239  )
240}
241
242// The project's .env files (not .env.example and the like) in the shell's
243// folder, with their text
244async function projectEnvFiles($) {
245  const cwd = await $.session.cwd()
246  const files = []
247  for (const entry of await $.fs.list(cwd)) {
248    if (entry.kind !== 'file' || !isEnvFile(entry.name)) continue
249    try {
250      files.push({ name: entry.name, path: `${cwd}/${entry.name}`, text: await $.fs.read(`${cwd}/${entry.name}`) })
251    } catch {}
252  }
253  return files.sort((a, b) => (a.name < b.name ? -1 : 1))
254}
255
256// Why a .env file may not be committed, or '' when it may: it holds pointers,
257// each a clean one to an item the Keychain holds, every secret-named value is
258// a pointer (or empty), every quote closes on its line, and the detector finds
259// no key in the rest. Whether the Keychain holds an item is asked once per
260// load (`memo.items` keeps only items found, so one added later is seen); an
261// answer that can't be had refuses, since this is a guard.
262async function keyIn(vault, text, cfg, memo) {
263  let pointers = 0
264  const rest = []
265  for (const line of String(text).split('\n')) {
266    const [assignment] = envAssignments(line)
267    if (assignment) {
268      const [name, value, raw] = assignment
269      // A quoted value that runs on below its line (a PEM key) can't be judged line by line
270      if (!quoteCloses(raw)) return `${name} has a quoted value that runs past its line`
271      if (isCleanPointer(value, cfg)) {
272        const pointer = parsePointer(value)
273        const key = JSON.stringify([pointer.service, pointer.account ?? ''])
274        if (!memo.items.has(key)) {
275          const exists = await vault.exists(pointer)
276          if (exists === false) return `${name} points to ${value}, which ${vault.the} doesn't hold`
277          if (exists !== true) return `whether ${vault.the} holds ${name}'s item (${value}) could not be checked`
278          memo.items.add(key)
279        }
280        pointers++
281        continue
282      }
283      if (value !== '' && SECRET_NAME.test(name)) return `${name} holds a key`
284    }
285    rest.push(line)
286  }
287  if (pointers === 0) return 'it holds no keychain: pointer'
288  return collect(() => scrubSecrets(rest.join('\n'), cfg)).tags.size === 0 ? '' : 'it holds a key'
289}
290
291// Before bin/keychain-env runs: load every pointer it will resolve, from the
292// .env files it will read. '' when all are loaded, else why not.
293async function preloadRunner($, vault, envFiles) {
294  const cwd = await $.session.cwd()
295  for (const file of envFiles) {
296    const path = isAbsolute(file) ? file : `${cwd}/${file}`
297    // A missing file: the runner resolves nothing from it (and stops if it was named)
298    if (!(await $.fs.exists(path))) continue
299    let text
300    try {
301      text = await $.fs.read(path)
302    } catch {
303      return `keychain-env reads ${file}, which secret-box can't read`
304    }
305    for (const [, pointer] of envPointers(text)) {
306      const got = await vault.read(vault.serviceRead(pointer.service, pointer.account))
307      for (const shown of got.printed || []) keep(shown, itemName(vault, pointer.service, pointer.account))
308      if (got.exitCode !== undefined && got.exitCode !== NOT_FOUND) return `${vault.reader} could not read ${pointer.service} (exit ${got.exitCode})`
309      if ((got.printed || []).some((p) => p.length < MIN_HIDDEN)) return `the value of ${pointer.service} is too short to be hidden`
310    }
311  }
312  return ''
313}
314
315// list_key_names: the pointers in the project's .env files, and the Keychain
316// items the box tagged or whose service starts with a listPrefixes entry.
317// Names only: no value is ever fetched. The names are the answer the user
318// sees; how to use them is the model's alone (its context).
319async function listKeyNames($, vault, options) {
320  const clean = (s) => String(s).replace(/[\x00-\x1f\x7f]/g, '?')
321  const pointers = []
322  try {
323    for (const f of await projectEnvFiles($)) {
324      for (const [name, p] of envPointers(f.text)) pointers.push(`  ${name} in ${f.name}: service ${clean(p.service)}${p.account ? `, account ${clean(p.account)}` : ''}`)
325    }
326  } catch {}
327  const prefixes = String((options || {}).listPrefixes || '').split(',').map((p) => p.trim()).filter(Boolean)
328  const items = []
329  let note = ''
330  try {
331    const listed = await vault.items()
332    for (const i of listed.items) {
333      if (i.comment !== BOX_COMMENT && !prefixes.some((p) => i.service.startsWith(p))) continue
334      const line = `  service ${clean(i.service)}${i.account ? `, account ${clean(i.account)}` : ''}${i.label && i.label !== i.service ? `, label ${clean(i.label)}` : ''}`
335      if (!items.includes(line)) items.push(line)
336    }
337    if (listed.isCut) note = `The ${vault.short} listing was cut off, so some names may be missing.`
338  } catch (err) {
339    note = `The ${vault.short} could not be listed (${String((err && err.message) || err).slice(0, 100)}).`
340  }
341  const result = [
342    NAMES_ONLY,
343    "Pointers in this project's .env files:",
344    ...(pointers.length ? pointers : ['  none']),
345    `${vault.short} items secret-box saved, or whose service starts with a listPrefixes entry:`,
346    ...(items.length ? items : ['  none']),
347    ...(note ? [note] : []),
348  ].join('\n')
349  const context = [
350    `To load a pointer's key, give its variable name to use_secret, or run the app with ${runnerPath($)} -- COMMAND to set every pointer. ` +
351      `To load a listed ${vault.short} item, give use_secret its service, and its account when several items share the service. ` +
352      `An item shows in this list once it is tagged (${vault.tagHint}) or its service starts with a listPrefixes entry.`,
353  ]
354  return { result, context }
355}
356
357// migrate_env_to_keychain. Per entry: save to the Keychain, read it back and
358// compare, and only then rewrite that line as a pointer. The file is written
359// with $.fs, not the Write tool, so no copy of the old text lands in
360// ~/.claude/file-history, and no backup is made. A tool the mod answers gets
361// no approval dialog from Claude Code, so the mod asks the user itself unless
362// a permission rule already allows or denies the call.
363async function migrateEnv($, vault, e, options) {
364  const file = String(e.file || '.env')
365  if (!isEnvFile(file)) return `${file} is not a .env file. Nothing was changed.`
366  const path = isAbsolute(file) ? file : `${await $.session.cwd()}/${file}`
367  let text
368  try {
369    text = await $.fs.read(path)
370  } catch {
371    return `${file} could not be read. Nothing was changed.`
372  }
373  const wanted = Array.isArray(e.names) && e.names.length ? e.names.map(String) : undefined
374  const lines = text.split('\n')
375  const plan = []
376  const leftAsIs = []
377  for (const [i, line] of lines.entries()) {
378    const eol = line.endsWith('\r') ? '\r' : ''
379    const m = /^([ \t\r\v\f]*(?:export[ \t\r\v\f]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t\r\v\f]*=)[ \t\r\v\f]*([^\n]*)$/.exec(eol ? line.slice(0, -1) : line)
380    if (!m) continue
381    const value = envValue(m[3])
382    if (value === '' || parsePointer(value) || !(wanted ? wanted.includes(m[2]) : SECRET_NAME.test(m[2]))) continue
383    // A quote that closes below its line (a PEM key) or a backslash escape
384    // (`"a\nb"`): this line alone is not the value, so it stays as it is
385    if (!quoteCloses(m[3]) || value.includes('\\')) {
386      leftAsIs.push(`${m[2]} (its value spans lines or holds backslash escapes: store it base64-encoded, as the secret-box skill's multi-line-values.md describes)`)
387      continue
388    }
389    plan.push({ i, name: m[2], value, head: m[1], eol })
390  }
391  const missing = (wanted || []).filter((n) => !plan.some((p) => p.name === n) && !leftAsIs.some((k) => k.startsWith(`${n} `)))
392  const notFound = missing.length ? ` Not found with a plaintext value: ${missing.join(', ')}.` : ''
393  if (plan.length === 0) {
394    const why = leftAsIs.length ? `Left as they were: ${leftAsIs.join('; ')}.` : `${file} has no ${wanted ? 'such entries' : 'secret-named entries'} with a plaintext value.`
395    return `${why} Nothing was changed.${notFound}`
396  }
397  const names = plan.map((p) => p.name).join(', ')
398
399  let decision = 'ask'
400  try {
401    ;({ decision } = await $.tool.check({ tool: MIGRATE, input: { file: e.file, names: e.names } }))
402  } catch {}
403  if (decision === 'deny') return 'A permission rule denies migrate_env_to_keychain. Nothing was changed.'
404  if (decision !== 'allow') {
405    const answer = await $.ui.ask(`Move ${names} from ${file} into ${vault.name}? Each line becomes a keychain: pointer once ${vault.the} holds its value.`, ['Move them', 'Cancel']).catch(() => '')
406    if (answer !== 'Move them') return `The user did not approve moving ${names}. Nothing was changed.`
407  }
408
409  const account = await vault.account(options)
410  if (!account || badName('account', account)) return `No ${vault.short} account: set the keychainAccount option. Nothing was changed.`
411  // PROJECTDIR/ENV_NAME, the folder that holds the .env file
412  const folder = path.split(/[\\/]/).slice(-2, -1)[0] || 'project'
413  const moved = []
414  for (const p of plan) {
415    const service = `${folder}/${p.name}`
416    const pointer = pointerTo(service, account)
417    if (!pointer) {
418      leftAsIs.push(`${p.name} (the folder or account name holds a character a pointer can't carry)`)
419      continue
420    }
421    try {
422      const before = await vault.read(vault.serviceRead(service, account))
423      if (before.exitCode === undefined && before.value !== p.value) {
424        leftAsIs.push(`${p.name} (${vault.the} already holds a different value for ${service})`)
425        continue
426      }
427      if (before.exitCode !== undefined && before.exitCode !== NOT_FOUND) {
428        leftAsIs.push(`${p.name} (${vault.failed(before)})`)
429        continue
430      }
431      if (before.exitCode === NOT_FOUND) await vault.save(service, account, p.value)
432      const after = await vault.read(vault.serviceRead(service, account))
433      if (after.exitCode !== undefined || after.value !== p.value) {
434        leftAsIs.push(`${p.name} (${vault.the} did not read back the same value)`)
435        continue
436      }
437      for (const shown of after.printed) keep(shown, itemName(vault, service, account))
438      lines[p.i] = `${p.head}${pointer}${p.eol}`
439      moved.push(`  ${p.name}=${pointer}`)
440    } catch (err) {
441      leftAsIs.push(`${p.name} (${String((err && err.message) || err).slice(0, 100)})`)
442    }
443  }
444  if (moved.length) {
445    try {
446      await $.fs.write(path, lines.join('\n'))
447    } catch {
448      return `${vault.The} now holds the values of ${moved.length} entries, but ${file} could not be rewritten, so it is unchanged. Nothing else was changed.`
449    }
450  }
451  // The report is the answer the user sees; how to run the app is the model's
452  const result = [
453    moved.length ? `Moved into ${vault.name} (account ${account}); these lines of ${file} are now pointers:` : `Nothing in ${file} was changed.`,
454    ...moved,
455    ...(leftAsIs.length ? [`Left as they were: ${leftAsIs.join('; ')}.`] : []),
456  ].join('\n') + notFound
457  return moved.length ? { result, context: [`Run the app with ${runnerPath($)} -- COMMAND${vault.inBash} so the pointers are set from ${vault.the}.`] } : { result }
458}
459
460export function register(on, options) {
461  // request_secret and use_secret answer with a live placeholder: keep it live
462  LIVE_TAG_TOOLS.add(TOOL)
463  LIVE_TAG_TOOLS.add(USE)
464  brand.name = 'secret-box'
465  // use_secret, list_key_names, migrate_env_to_keychain and the pre-load are on
466  // unless this is known not to be a Mac, and the box saves to the Keychain
467  // only on one (unless backend says otherwise)
468  const memo = { items: new Set() }
469  const refuse = [true, 'true'].includes((options || {}).refuseUnmaskable)
470  // The detector's settings, for the commit guard's own scan of a .env file
471  const cfg = makeConfig(options || {})
472  // The nudge is said once per session: at the first prompt when the session
473  // starts beside .env files, else at the first tool call that touches one or
474  // has a key hidden from its result
475  const once = { isNudged: false, isEnvAtStart: false }
476
477  // 0. Outside the redactor, so these see what it made of each call: pointers
478  //    it hid are put back, and the nudge rides along when its moment comes.
479  //    (A match-all matcher: the redactor holds the bare registration.)
480  on('tool.call', { tool: /^/ }, async ($, e, next) => {
481    // `keychain:[REDACTED-SECRET-…]` would be restored into the key itself,
482    // written where a service name belongs
483    if (cfg.restore && cfg.restoreTools.has(e.tool) && /keychain:\[REDACTED-SECRET-[0-9a-f]{8}\]/.test(JSON.stringify(e))) {
484      return {
485        deny:
486          'secret-box: a .env pointer names a Keychain item: after keychain: write its service name (keychain:openai, ' +
487          'keychain:myapp/STRIPE_SECRET_KEY), never a placeholder, which would put the key itself in its place.',
488      }
489    }
490    const r = showPointers(await next(e), cfg)
491    const platform = await platformIn($, memo)
492    if (once.isNudged || r.deny !== undefined || platform === 'other') return r
493    const input = JSON.stringify(e)
494    const hidKey = !LIVE_TAG_TOOLS.has(e.tool) && (JSON.stringify([r.result, r.text]).match(SECRET_TAG) || []).some((t) => !input.includes(t))
495    if (!hidKey && envFiles(e).length === 0) return r
496    once.isNudged = true
497    // The answer as it came, context added: core's own answer (an errored
498    // one too) keeps the handle on its messages, so its result isn't checked
499    // against the tool's output shape again
500    return { ...r, context: [...(r.context ?? []), await nudgeText($, vaultOf($, platform), platform)] }
501  }).catch(($, e, next) => next(e))
502  on('prompt.attachment', { type: /^/ }, async ($, e, next) => {
503    const r = await next(e)
504    if (typeof r.text !== 'string') return r
505    const shown = showPointers({ result: r.text }, cfg)
506    return shown.result === r.text ? r : { text: shown.result }
507  }).catch(($, e, next) => next(e))
508
509  // What the redactor's refusals tell Claude to do: never write a key to a file
510  brand.shellHint =
511    'If it is a Keychain item, call use_secret and write exactly the form it gives, which reads the key inside the command. ' +
512    "Otherwise ask the user to save it with request_secret, then do the same. Don't write it into a file."
513  brand.staleHint = 'Call use_secret (or request_secret) for that key again and use the placeholder it gives.'
514  redactor(on, options || {})
515
516  // 1. Give Claude the tools, and teach the vault the secrets already in .env
517  on('session.start', async ($, e, next) => {
518    const platform = await platformIn($, memo)
519    const vault = vaultOf($, platform)
520    const isKeychain = boxBackend((options || {}).backend, platform) !== 'dotenv'
521    // On Windows a placeholder works in the Bash tool (Git Bash) alone
522    const inBash = platform === 'windows' ? ' Use the Bash tool (Git Bash) for such a command, not PowerShell: PowerShell gets no real values.' : ''
523    if (platform === 'unknown') {
524      $.ui.toast("secret-box couldn't tell whether this is a Mac (uname didn't answer). use_secret stays on; the box won't save until the backend option is set to keychain or dotenv.")
525    }
526    await $.tool.register({
527      name: 'request_secret',
528      description: isKeychain
529        ? 'Ask the user for a secret (API key, token, password) without it ever entering the conversation. ' +
530          `The user pastes it into a masked box and the mod saves it to ${vault.name} under that name. ` +
531          'You get back a placeholder like [REDACTED-SECRET-1a2b3c4d]. Write that placeholder in a command ' +
532          `and it becomes the real value when the tool runs.${inBash} Use this INSTEAD of asking the user to paste a ` +
533          `secret into chat. For a secret already in ${vault.the}, use use_secret.`
534        : 'Ask the user for a secret (API key, token, password) without it ever entering the conversation. ' +
535          'The user pastes it into a masked box and the mod saves it to the project .env. You get back the ' +
536          'variable name and a placeholder like [REDACTED-SECRET-1a2b3c4d]. Write that placeholder in a command ' +
537          `or file and it becomes the real value when the tool runs.${inBash} Use this INSTEAD of asking the user to ` +
538          'paste a secret into chat.',
539      inputSchema: {
540        type: 'object',
541        properties: {
542          name: {
543            type: 'string',
544            description: isKeychain
545              ? `The ${vault.short} service to save it under: PROJECTDIR/ENV_NAME for this project's own key (myapp/STRIPE_SECRET_KEY), a short shared name (openai) for a key used across projects`
546              : 'Environment variable name, e.g. STRIPE_SECRET_KEY',
547          },
548          reason: { type: 'string', description: 'One short line telling the user why it is needed' },
549        },
550        required: ['name', 'reason'],
551      },
552      isDeferred: false,
553    })
554    if (platform !== 'other') {
555      const runner = runnerPath($)
556      await $.tool.register({
557        name: 'use_secret',
558        description:
559          `Load a secret the user keeps in ${vault.name} (a generic ${platform === 'windows' ? 'credential' : 'password'}) without it ever entering the ` +
560          'conversation. Give its service name, or the name of a variable whose .env value is a keychain: pointer, ' +
561          'and the account when several items share a service. You get back how to use it in a Bash command: usually ' +
562          'a placeholder like [REDACTED-SECRET-1a2b3c4d] that becomes the real value when the command runs and is ' +
563          `hidden again in the output.${inBash} Use this INSTEAD of running ${vault.directRead} yourself. Never ` +
564          `write the value into a file. To run an app with every .env pointer set, use ${runner} -- COMMAND. If the ` +
565          'item is missing, you get the command the user can run to add it.',
566        inputSchema: {
567          type: 'object',
568          properties: {
569            service: { type: 'string', description: `The ${vault.short} service (openai, myapp/STRIPE_SECRET_KEY), or a .env variable name whose value is a keychain: pointer` },
570            account: { type: 'string', description: `The ${vault.short} account, only when several items share the service. Given, \`service\` is always read as a service` },
571          },
572          required: ['service'],
573        },
574        isDeferred: false,
575      })
576      await $.tool.register({
577        name: 'list_key_names',
578        description:
579          `${NAMES_ONLY} Lists the keychain: pointers in this project's .env files, and ${vault.name} items ` +
580          'secret-box saved or whose service starts with one of the listPrefixes setting: service, account and label. ' +
581          'Call it to find the name to give use_secret.',
582        inputSchema: { type: 'object', properties: {} },
583        isDeferred: false,
584      })
585      await $.tool.register({
586        name: 'migrate_env_to_keychain',
587        description:
588          `Move plaintext keys out of one .env file into ${vault.name}, and rewrite each moved line as a ` +
589          'keychain: pointer. By default it moves every secret-named entry that holds a plaintext value; give names ' +
590          `to choose. Each goes to service PROJECTDIR/NAME, account ${platform === 'windows' ? '%USERNAME%' : '$USER'}, tagged for list_key_names. A value is ` +
591          'written, read back and compared before its line changes; any failure leaves that line as it was. The user ' +
592          'confirms first. Never returns a value.',
593        inputSchema: {
594          type: 'object',
595          properties: {
596            file: { type: 'string', description: 'The .env file, relative to the current folder (default .env)' },
597            names: { type: 'array', items: { type: 'string' }, description: 'The variables to move (default: every secret-named one with a plaintext value)' },
598          },
599        },
600      })
601    }
602    await learnDotenv($, ['.env'])
603    // Beside .env files on a Mac, the nudge goes with the first prompt
604    try {
605      once.isEnvAtStart = platform !== 'other' && (await projectEnvFiles($)).length > 0
606    } catch {}
607    return next(e)
608  })
609
610  // The first prompt of a session that starts beside .env files carries the nudge
611  on('prompt.submit', { text: /^/ }, async ($, e, next) => {
612    if (once.isNudged || !once.isEnvAtStart || e.command || e.isMeta) return next(e)
613    once.isNudged = true
614    const platform = await platformIn($, memo)
615    return next({ ...e, context: [...(e.context ?? []), await nudgeText($, vaultOf($, platform), platform)] })
616  }).catch(($, e, next) => next(e))
617
618  // 2. The guard: .env may be read (its secrets come back hidden), never
619  //    committed, from Bash or (on Windows) PowerShell, where git runs the same
620  on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
621    const why = await commitBlockReason($, vaultOf($, await platformIn($, memo)), String(e.command || ''), cfg, memo)
622    return why ? { deny: `secret-box: ${why} A pushed key can't be taken back.` } : next(e)
623  }).catch(() => ({ deny: 'secret-box: the .env guard failed, so this call is blocked to be safe.' }))
624
625  // 2b. PowerShell gets no real values (it isn't in restoreTools, and the
626  //     redactor's rules for what may go into a command are Bash's), so a
627  //     placeholder there would run as its own text. Refused, with the way
628  //     that works: the Bash tool, which on Windows is Git Bash.
629  on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
630    if (!cfg.restore || cfg.restoreTools.has('PowerShell')) return next(e)
631    const live = (String(e.command || '').match(SECRET_TAG) || []).filter((t) => restore(t) !== t)
632    if (live.length === 0) return next(e)
633    // No placeholder in the reason: the redactor would quote it as a literal
634    return {
635      deny:
636        `secret-box: ${live.length === 1 ? 'the placeholder in this command becomes its real value' : 'the placeholders in this command become their real values'} only in the Bash tool (Git Bash), ` +
637        'not in PowerShell, so it would run with the placeholder text. Nothing ran. Run the same command with the Bash tool instead.',
638    }
639  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-box: could not check this PowerShell command for placeholders, so it is blocked to be safe.' }))
640
641  // 3. A call that reads a .env file: learn its secret values before it runs,
642  //    so the redactor hides them in the output even when they look weak
643  //    (a word password). Reading .env is fine; its secrets come back hidden.
644  on('tool.call', { tool: /^(Bash|PowerShell|Read|Grep|Edit|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
645    const files = envFiles(e)
646    if (files.length > 0) await learnDotenv($, files)
647    return next(e)
648  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-box: could not check this .env file, so the call is blocked to be safe.' }))
649
650  // 3b. A command that reads the Keychain itself (`security find-generic-password
651  //     -s NAME -w`): load the item it reads into the vault before it runs, by
652  //     the same match options and keychain files, so what it prints (-w on
653  //     stdout, -g on stderr, as text or hex) comes back hidden. A read it
654  //     can't promise to hide goes to unmaskable(). Every command is read,
655  //     not only ones that spell find-*-password: a read can be spelled
656  //     around that (find-generic-pass\word, security -i < commands).
657  //     A hook nested under the redactor's that throws fails into the
658  //     redactor's .catch, which withholds the call, not into its own. So
659  //     failures are handled here.
660  //     On Windows the read is bin/credman get (or credman.ps1, in Bash or
661  //     PowerShell), and the runner the same keychain-env.
662  on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
663    // PowerShell takes a backslash as a path's own character, never an escape
664    // (its escape is the backtick, which the parser can't follow and so calls
665    // unknown): read it as / so C:\x\bin\credman.ps1 is the script it names
666    const command = e.tool === 'PowerShell' ? String(e.command || '').replace(/\\/g, '/') : String(e.command || '')
667    let reads = []
668    let vault
669    try {
670      const platform = await platformIn($, memo)
671      const windows = { windows: platform === 'windows' }
672      if (e.tool === 'PowerShell' && !windows.windows) return next(e)
673      if (keychainReads(command, {}, windows).length > 0 && platform !== 'other') {
674        vault = vaultOf($, platform)
675        reads = keychainReads(command, { USER: await $.env.get('USER'), LOGNAME: await $.env.get('LOGNAME'), HOME: await $.env.get('HOME') }, windows)
676      }
677    } catch {
678      reads = [{ unknown: 'secret-box could not read it' }]
679    }
680    for (const read of reads) {
681      let why = read.unknown
682      if (!why && read.envFiles) {
683        // bin/keychain-env: every pointer in the .env files it will read
684        try {
685          why = await preloadRunner($, vault, read.envFiles)
686        } catch (err) {
687          why = String((err && err.message) || err)
688        }
689      } else if (!why) {
690        try {
691          const got = await vault.read(read)
692          const service = read.kind === 'credman' ? read.service : ((read.criteria || []).find(([flag]) => flag === '-s') || [])[1]
693          for (const shown of got.printed || []) keep(shown, service ? itemName(vault, service) : undefined)
694          // No such item: the command finds none either. Locked or refused: it might still print it
695          if (got.exitCode !== undefined && got.exitCode !== NOT_FOUND) why = `${vault.reader} could not read the item (exit ${got.exitCode})`
696          // The vault can't find a form this short where it turns up
697          else if ((got.printed || []).some((p) => p.length < MIN_HIDDEN)) why = 'its value is too short to be hidden'
698        } catch (err) {
699          why = String((err && err.message) || err)
700        }
701      }
702      const refusal = why && unmaskable(why, refuse)
703      if (refusal) return refusal
704    }
705    return next(e)
706  }).catch(($, e, next) => next(e))
707
708  // 3c. use_secret: load a Keychain item and answer with a placeholder, or the
709  //     way to read it inside a command, never the value. What `security`
710  //     prints is never put in the answer. Failures are answered here, for the
711  //     same reason as above.
712  on('tool.call', { tool: USE }, async ($, e, next) => {
713    const platform = await platformIn($, memo)
714    if (platform === 'other') return { result: 'use_secret needs macOS or Windows. Nothing was loaded.' }
715    const vault = vaultOf($, platform)
716    let service = String(e.service ?? '')
717    let account = String(e.account ?? '')
718    const why = (service ? badName('service', service) : `Give the ${vault.short} service name.`) || badName('account', account)
719    if (why) return { result: `${why} Nothing was loaded.` }
720
721    try {
722      // Which item: with no account, a variable in the project's .env files
723      // whose value is a pointer names it; otherwise `service` is the service
724      let via = ''
725      let runner = ''
726      if (!account) {
727        const name = service
728        let defs = []
729        try {
730          defs = (await projectEnvFiles($)).flatMap((f) => envPointers(f.text).filter(([n]) => n === name).map(([, p]) => ({ file: f.name, ...p })))
731        } catch {}
732        const items = [...new Set(defs.map((d) => pointerTo(d.service, d.account)))]
733        const files = [...new Set(defs.map((d) => d.file))].join(', ')
734        if (items.length > 1) return { result: `${name} points to different ${vault.short} items in ${files}: ${items.join(' and ')}. Call use_secret with the service and account you mean. Nothing was loaded.` }
735        if (defs.length) {
736          service = defs[0].service
737          account = defs[0].account ?? ''
738          via = ` through the ${name} pointer in ${files}`
739          runner = ` To run an app with every .env pointer set, use ${runnerPath($)} -- COMMAND.`
740        }
741      }
742      const got = await vault.read(vault.serviceRead(service, account))
743      const { value, printed, exitCode } = got
744      const item = `The ${vault.short} item ${service}${account ? ` (account ${account})` : ''}`
745      if (exitCode === undefined) {
746        // Every form the vault's reader prints it in is hidden, should a command print the item itself
747        for (const shown of printed) keep(shown, itemName(vault, service, account))
748        if (value) {
749          const tag = keep(value, itemName(vault, service, account))
750          // The ONLY things Claude learns: the service, and how to use the
751          // value without seeing it. The user's row gets the one line.
752          const use = howToUse(vault, service, account, value, tag)
753          const inBash = platform === 'windows' ? ' Use the Bash tool (Git Bash) for that command: PowerShell gets no real values.' : ''
754          return { result: use.loaded(`Loaded ${service}${account ? ` (account ${account})` : ''} from ${vault.the}${via}`), context: [use.guide + inBash + runner] }
755        }
756        if (value === undefined) return { result: `${item} holds bytes that are not text, so it can't be used in a command. Nothing was loaded.` }
757      }
758      const hint = vault.addHint(service, account || (await vault.account(options)))
759      const where = platform === 'windows' ? 'in their own terminal window (not through Claude, and not with !)' : 'in a terminal'
760      if (exitCode === NOT_FOUND) {
761        return { result: `No ${vault.short} item has the service ${service}${account ? ` and account ${account}` : ''}. Ask the user to add it ${where} with this command, which prompts for the value so the key never lands in shell history:\n${hint}\nThen call use_secret again.` }
762      }
763      if (exitCode === undefined) {
764        return { result: `${item} holds no value. Ask the user to set one ${where} with this command, which prompts for it:\n${hint}\nThen call use_secret again.` }
765      }
766      return { result: `${item} could not be read (${vault.failed(got)}): ${vault.unreadable}, then call use_secret again.` }
767    } catch (err) {
768      return { result: `secret-box could not read ${vault.the}: ${String((err && err.message) || err).slice(0, 200)}. Nothing was loaded.` }
769    }
770  }).catch(() => ({ result: 'secret-box could not read the vault. Nothing was loaded.' }))
771
772  // 3d. list_key_names: names only. The pointers in the project's .env files,
773  //     and the Keychain items the box saved (tagged) or whose service starts
774  //     with a listPrefixes entry. The dump it reads holds every item's names,
775  //     so it is filtered here and never handed on whole.
776  on('tool.call', { tool: LIST }, async ($, e, next) => {
777    const platform = await platformIn($, memo)
778    if (platform === 'other') return { result: 'list_key_names needs macOS or Windows.' }
779    try {
780      return await listKeyNames($, vaultOf($, platform), options)
781    } catch {
782      return { result: `${NAMES_ONLY}\nsecret-box could not list them.` }
783    }
784  }).catch(() => ({ result: 'secret-box could not list key names.' }))
785
786  // 3e. migrate_env_to_keychain: move plaintext keys from one .env file into
787  //     the Keychain, each line becoming a pointer only once the Keychain reads
788  //     its value back
789  on('tool.call', { tool: MIGRATE }, async ($, e, next) => {
790    const platform = await platformIn($, memo)
791    if (platform === 'other') return { result: 'migrate_env_to_keychain needs macOS or Windows. Nothing was changed.' }
792    try {
793      const answer = await migrateEnv($, vaultOf($, platform), e, options)
794      return typeof answer === 'string' ? { result: answer } : answer
795    } catch (err) {
796      return { result: `migrate_env_to_keychain stopped before rewriting the file (${String((err && err.message) || err).slice(0, 100)}). The file is unchanged.` }
797    }
798  }).catch(() => ({ result: 'migrate_env_to_keychain failed. The file is unchanged.' }))
799
800  // @.env would attach the raw file to your prompt
801  on('prompt.mention', { path: /(^|[\\/])\.env(\.[\w.-]+)?$/ }, async ($, e, next) =>
802    isEnvFile(e.path) ? { deny: 'secret-box: ask Claude to read .env instead; its secrets come back hidden' } : next(e))
803    .catch(() => ({ deny: 'secret-box: the .env guard failed' }))
804
805  // 4. The box. Claude calls the tool: open it, wait for you, save, answer.
806  on('tool.call', { tool: TOOL }, async ($, e, next) => {
807    const name = String(e.name || '').trim()
808    // Refuse before anyone types, rather than fall back to a plaintext .env, when
809    // backend keychain was set off macOS, or auto can't tell what this is
810    const platform = await platformIn($, memo)
811    const backend = boxBackend((options || {}).backend, platform)
812    const isKeychain = backend === 'keychain'
813    const vault = vaultOf($, platform)
814    // .env needs a variable name; the Keychain takes a service (myapp/STRIPE_SECRET_KEY)
815    if (!isKeychain && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
816      return { result: `"${name}" is not a valid variable name. Use letters, digits and underscores.` }
817    }
818    if (isKeychain && (!name || badName('name', name))) return { result: `"${name}" can't be a ${vault.short} service name. ${badName('name', name) || 'Give one.'}` }
819    if (isKeychain && platform === 'other') return { result: `secret-box's Keychain backend needs macOS or Windows, so ${name} was not asked for. Set the backend option to auto or dotenv.` }
820    if (backend === 'unknown') return { result: `secret-box couldn't tell whether this is a Mac (uname didn't answer), so ${name} was not asked for and nothing was written. Ask the user to set the backend option to keychain or dotenv.` }
821    // A plain -p run has no one to type into the box
822    if ((await $.session.surfaces()).length === 0) {
823      return { result: `secret-box needs an interactive session to collect ${name}. Ask the user to run Claude Code interactively.` }
824    }
825    if (pending) return { result: `secret-box is already waiting for ${pending.name}. Ask for one secret at a time.` }
826
827    // `answer` stays undefined until Enter (the value) or Escape (empty)
828    pending = { name, reason: String(e.reason || ''), value: '', answer: undefined, isInBand: false }
829    const opened = await $.ui.open({ id: PANE, title: 'secret-box', focus: true, closeOnEscape: true, holdToasts: true, rows: 7 })
830    // A narrow terminal (under 144 columns) keeps the pane undrawn: use the band above the prompt instead
831    if (!opened.isPlaced) {
832      pending.isInBand = true
833      await $.ui.close({ id: PANE })
834      $.ui.invalidate('ui.render')
835      $.ui.toast(`Claude wants ${name}: press ctrl+x then tab to type it in the box above the prompt`, { timeoutMs: 10000 })
836    }
837
838    // A hook's own run time is capped at 10 s, but time inside a mods API call
839    // is not counted, so wait for the person in short `sleep` processes.
840    const deadline = (await $.clock.now()) + 10 * 60_000
841    while (pending.answer === undefined && !next.signal.aborted && (await $.clock.now()) < deadline) {
842      await pause($)
843    }
844    const value = pending.answer
845    pending = null
846    await $.ui.close({ id: PANE })
847    $.ui.invalidate('ui.render')
848    if (!value) return { result: `The user cancelled. ${name} was not saved.` }
849
850    try {
851      if (isKeychain) {
852        const account = await vault.account(options)
853        const why = account ? badName('keychainAccount', account) : `No ${vault.short} account: set the keychainAccount option.`
854        if (why) return { result: `Saving ${name} failed: ${why}` }
855        await vault.save(name, account, value)
856        const tag = keep(value, itemName(vault, name, account))
857        const pointer = pointerTo(name, account)
858        $.ui.toast(`${name} saved to ${vault.short}`)
859        // The ONLY things Claude learns: the name, the place, and a
860        // placeholder. The user's row gets the one line.
861        const use = howToUse(vault, name, account, value, tag)
862        const inBash = platform === 'windows' ? ' Use the Bash tool (Git Bash) for that command: PowerShell gets no real values.' : ''
863        return {
864          result: use.loaded(`The user entered ${name}, saved to ${vault.name} (account ${account})`),
865          context: [
866            `${name} is saved as service ${name}, account ${account}; .env was not touched. ${use.guide}${inBash} In a later session, call use_secret with service ${name} and account ${account}.` +
867              (pointer ? ` For an app that reads .env, write the line ENV_NAME=${pointer} there, never the value, and run the app with ${runnerPath($)} -- COMMAND.` : ''),
868          ],
869        }
870      }
871      const where = await saveToDotenv($, await $.session.cwd(), name, value)
872      const tag = keep(value, `${name} in .env`)
873      $.ui.toast(`${name} saved to ${where}`)
874      // The ONLY things Claude learns: the name, the place, and a placeholder
875      return { result: `The user entered ${name}. It was saved to ${where}. You can't see the value. To use it in a command or a file, write ${tag}: it becomes the real value when the tool runs and is hidden again in the output. Code should read it from the environment.` }
876    } catch (err) {
877      return { result: `Saving ${name} failed: ${String(err && err.message || err).slice(0, 300)}` }
878    }
879  }).catch(async ($, e, next) => {
880    // If the hook itself fails (or times out), drop the request, close the box and tell Claude plainly
881    pending = null
882    try { await $.ui.close({ id: PANE }) } catch {}
883    $.ui.invalidate('ui.render')
884    $.ui.log('request_secret failed: ' + JSON.stringify(next.error).slice(0, 500))
885    return { result: 'secret-box failed before anything was saved. Nothing was written.' }
886  })
887
888  // Draw the box, in our pane or (narrow terminal) in the band above the prompt
889  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
890    if (e.requestId !== PANE || !pending) return next(e)
891    return drawBox($, e, pending)
892  })
893  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
894    if (!pending || !pending.isInBand) return next(e)
895    return drawBox($, e, pending)
896  })
897
898  // A refusal of secret-box's own, drawn in the user's view as one calm line.
899  // The model still reads the whole text: a refusal can't carry context, so
900  // only its row changes. An errored row draws the error text whatever its
901  // output, so this draws a tree of its own rather than rewrite the output.
902  on('ui.render', { component: 'ToolResult', props: { isErrored: true } }, async ($, e, next) => {
903    const line = calmLine(e.props.output)
904    if (!line) return next(e)
905    const { Text } = $.ui.resolve(e)
906    return <Text dimColor>{line}</Text>
907  })
908
909  // Escape (or any close) on the pane before Enter means cancel
910  on('ui.close', async ($, e, next) => {
911    if (e.id === PANE && pending && !pending.isInBand) pending.answer = ''
912    return next(e)
913  }).catch(($, e, next) => next(e))
914}
915
916// One short wait, in a process, so it is not the hook's own time. Mac and
917// Linux have `sleep`. A native Windows terminal does not, so there node waits
918// where it is installed, else ping, which every Windows has (about 1 s).
919export const WAITS = {
920  unix: [['sleep', '0.3']],
921  windows: [['node', '-e', 'setTimeout(() => {}, 300)'], ['ping', '-n', '2', '127.0.0.1']],
922}
923let waits = null
924async function pause($) {
925  waits ||= [...((await $.env.get('OS')) === 'Windows_NT' ? WAITS.windows : WAITS.unix)]
926  for (;;) {
927    try {
928      return await $.process.run(waits[0])
929    } catch (err) {
930      // Only a command that is not there moves on to the next one
931      if (waits.length > 1 && isNotFound(err)) waits.shift()
932      else throw err
933    }
934  }
935}
936export function isNotFound(err) {
937  return /ENOENT|not found/i.test(String((err && err.message) || err))
938}
939
940// What the user sees of a refusal secret-box made, by what it was about, or
941// undefined for any other error (a command's own failure is drawn as it is).
942// The model reads the refusal whole; these say only what happened.
943const CALM = [
944  [/the user did not allow this call/, 'secret-box: not run, as you chose.'],
945  [/waits until plan mode ends/, 'secret-box: held until plan mode ends. Claude was asked to put this step in the plan.'],
946  [/no longer holds/, 'secret-box: that was an old placeholder, from before secret-box reloaded. Claude was asked to load the key again.'],
947  [/character the shell reads/, 'secret-box: this key needs another form in a command. Claude was told which.'],
948  [/A pushed key can't be taken back/, 'secret-box: kept a .env file with keys out of git. Claude was told why.'],
949  [/write its service name/, 'secret-box: a .env pointer takes a service name, not a placeholder. Claude was told how to write it.'],
950  [/reads the Keychain, but/, 'secret-box: Claude was asked to load this key with use_secret instead.'],
951  [/only in the Bash tool/, 'secret-box: a key works in Bash, not PowerShell. Claude was asked to run it in Bash.'],
952]
953export function calmLine(output) {
954  if (typeof output !== 'string') return undefined
955  const text = errorText(output.replace(/^Error: /, ''), undefined)
956  if (!text.startsWith('secret-box: ')) return undefined
957  return (CALM.find(([pattern]) => pattern.test(text)) || [, 'secret-box held this call back to keep a key out of view. Claude was told why.'])[1]
958}
959
960// .env, .env.local, .env.production ... but not .env.example and friends
961export function isEnvFile(path) {
962  const base = String(path || '').split(/[\\/]/).pop()
963  return /^\.env(\.[\w.-]+)?$/.test(base) && !/\.(example|sample|template|dist)$/.test(base)
964}
965
966// The .env files a tool call reads or names, as written in the call
967function envFiles(e) {
968  const direct = [e.file_path, e.notebook_path, e.path].filter(isEnvFile)
969  const named = String(e.command || '').match(/(?:^|[^\w.\/-])((?:[\w.\/-]*\/)?\.env(?:\.[\w.-]+)?)(?![\w.-])/g) || []
970  return [...direct, ...named.map(m => m.replace(/^[^\w.\/-]/, '')).filter(isEnvFile)]
971}
972
973// Read .env files and hand every secret-named value to the vault, so it is
974// hidden wherever it turns up. Never throws: a file it can't read teaches it
975// nothing, and the redactor still scans the output.
976async function learnDotenv($, files) {
977  let cwd = ''
978  try { cwd = await $.session.cwd() } catch {}
979  for (const f of files) {
980    try {
981      const path = isAbsolute(f) ? f : f.startsWith('~/') ? f : cwd + '/' + f
982      if (path.startsWith('~/') || !(await $.fs.exists(path))) continue
983      for (const m of (await $.fs.read(path)).matchAll(ASSIGNMENT)) {
984        const value = m[3].trim().replace(/^["']|["']$/g, '')
985        // A pointer only names a Keychain item: it stays readable
986        if (value.length >= 6 && SECRET_NAME.test(m[2]) && !parsePointer(envValue(m[3]))) keep(value, `${m[2]} in ${f}`)
987      }
988    } catch {}
989  }
990}
991
992// /x on Mac and Linux; C:\x, C:/x or \\server\share on Windows
993export function isAbsolute(path) {
994  return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
995}
996
997// The repositories a command's git calls run in: each `git -C <dir>`, and the
998// session's own repository for a plain `git`
999function gitDirs(cmd) {
1000  const dirs = new Set()
1001  for (const m of cmd.matchAll(/\bgit((?:[ \t]+-c[ \t]+\S+|[ \t]+-C[ \t]+(?:"[^"]*"|'[^']*'|\S+))*)/g)) {
1002    const c = m[1].match(/-C[ \t]+("[^"]*"|'[^']*'|\S+)/g)
1003    if (!c) dirs.add('')
1004    else dirs.add(c.at(-1).replace(/^-C[ \t]+/, '').replace(/^["']|["']$/g, ''))
1005  }
1006  return [...dirs]
1007}
1008
1009// Why a Bash command must be blocked: it stages or commits a .env file. A
1010// .env file of pointers may go in (holdsNoKey): it names Keychain items and
1011// holds no key. Any file that can't be read and checked is blocked as before.
1012async function commitBlockReason($, vault, cmd, cfg, memo) {
1013  // Why a .env file can't go in, or '' when it can; one that can't be read can't
1014  const keyOf = async (read) => {
1015    let text
1016    try {
1017      text = await read()
1018    } catch {
1019      return 'it could not be read'
1020    }
1021    return keyIn(vault, text, cfg, memo)
1022  }
1023  if (/\bgit\b[^;&|]*\badd\b/.test(cmd) && /\.env\b(?!\.(example|sample|template|dist))/.test(cmd)) {
1024    // Only .env files named plainly, in the current folder's repository, can be checked
1025    const named = envFiles({ command: cmd })
1026    if (/\s-C[\s=]/.test(cmd) || named.length === 0) return 'staging a .env file is blocked.'
1027    const cwd = await $.session.cwd()
1028    for (const f of named) {
1029      const why = await keyOf(() => $.fs.read(isAbsolute(f) ? f : `${cwd}/${f}`))
1030      if (why) return `staging a .env file is blocked: ${why}. A .env file can be committed once every key in it is a keychain: pointer to an item ${vault.the} holds.`
1031    }
1032  }
1033  if (!/\bgit\b[^;&|]*\bcommit\b/.test(cmd)) return ''
1034  // The index is read before the command runs. If the same command stages
1035  // (git add ... && git commit, or commit -a), count what it could stage too:
1036  // every changed or untracked file git does not ignore.
1037  const stages = /\bgit\b[^;&|]*\b(add|stage)\b/.test(cmd) || /\bcommit\b[^;&|]*\s(-a|--all|-\w*a\w*)\b/.test(cmd)
1038  for (const dir of gitDirs(cmd)) {
1039    const git = (...args) => $.process.run(['git', ...(dir ? ['-C', dir] : []), ...args])
1040    const checks = [git('diff', '--cached', '--name-only')]
1041    if (stages) checks.push(git('ls-files', '--modified', '--others', '--exclude-standard'))
1042    const [index, tree] = await Promise.all(checks)
1043    for (const r of [index, tree].filter(Boolean)) {
1044      if (r.exitCode !== 0) return `could not check what this commit includes${dir ? ' in ' + dir : ''}, so it is blocked.`
1045    }
1046    // Staged: what the index holds. Paths are from the repository's top.
1047    for (const env of index.stdout.split('\n').filter(isEnvFile)) {
1048      const staged = async () => {
1049        const r = await git('show', `:${env}`)
1050        if (r.exitCode !== 0) throw new Error('unreadable')
1051        return r.stdout
1052      }
1053      const why = await keyOf(staged)
1054      if (why) return `this commit would include ${env}: ${why}. Unstage it with git rm --cached, or make every key in it a keychain: pointer to an item ${vault.the} holds.`
1055    }
1056    // Stageable: the working file. Paths are from the folder git ran in.
1057    const base = !dir ? await $.session.cwd() : isAbsolute(dir) ? dir : `${await $.session.cwd()}/${dir}`
1058    for (const env of (tree ? tree.stdout.split('\n') : []).filter(isEnvFile)) {
1059      const why = await keyOf(() => $.fs.read(`${base}/${env}`))
1060      if (why) {
1061        return `this command could stage and commit ${env}: ${why}. Add it to .gitignore, or stage named files in a separate step.`
1062      }
1063    }
1064  }
1065  return ''
1066}
1067
1068// The field shows dots; the real text is kept in `p.value`, never drawn.
1069function drawBox($, e, p) {
1070  const { Box, Text, Input } = $.ui.resolve(e)
1071  return (
1072    <Box flexDirection="column" paddingX={1}>
1073      <Text bold>Claude is asking for <Text color="yellow">{p.name}</Text></Text>
1074      {p.reason ? <Text dimColor>{p.reason}</Text> : null}
1075      <Input
1076        key="secret"
1077        label="value "
1078        placeholder="paste it here, it stays masked"
1079        value={DOT.repeat(p.value.length)}
1080        submitLabel="save"
1081        autoFocus
1082        onInput={text => {
1083          // Everything up to the first non-dot is the part we already hold;
1084          // the rest is what was just typed or pasted. Backspace shortens the dots.
1085          let kept = 0
1086          while (kept < text.length && text[kept] === DOT) kept++
1087          p.value = p.value.slice(0, Math.min(kept, p.value.length)) + text.slice(kept)
1088          $.ui.invalidate('ui.render')
1089        }}
1090        onSubmit={() => { p.answer = p.value }}
1091      />
1092      <Text dimColor>{p.value.length ? `${p.value.length} characters · ` : ''}Enter saves · {p.isInBand ? 'ctrl+x tab to focus, Enter on empty cancels' : 'Esc cancels'} · Claude never sees the value</Text>
1093    </Box>
1094  )
1095}
1096
1097async function saveToDotenv($, cwd, name, value) {
1098  const path = cwd + '/.env'
1099  const old = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
1100  const line = `${name}=${quote(value)}`
1101  const lines = old.split('\n').filter((l, i, all) => !(i === all.length - 1 && l === ''))
1102  const at = lines.findIndex(l => l.startsWith(name + '='))
1103  if (at >= 0) lines[at] = line
1104  else lines.push(line)
1105  await $.fs.write(path, lines.join('\n') + '\n')
1106
1107  const gi = cwd + '/.gitignore'
1108  const ignore = (await $.fs.exists(gi)) ? await $.fs.read(gi) : ''
1109  const isIgnored = ignore.split('\n').some(l => ['.env', '.env*', '*.env', '/.env'].includes(l.trim()))
1110  if (!isIgnored) await $.fs.write(gi, ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + '.env\n')
1111  return '.env' + (isIgnored ? '' : ' (and added .env to .gitignore)')
1112}
1113
1114// Plain values stay bare; anything with spaces, quotes or # gets double quotes.
1115function quote(v) {
1116  return /^[A-Za-z0-9_\-.\/:+=@]*$/.test(v) ? v : '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'
1117}
1118
hooks/redact.ts 436 lines
1import type { Register, PromptContextBlock } from 'claude-code'
2import {
3  makeConfig,
4  scrubSecrets,
5  scrubPii,
6  restore,
7  mint,
8  walk,
9  collect,
10  escapeLiteralTags,
11  type WalkState,
12} from './detect.ts'
13
14// transcript-redactor: keeps secrets (and, if asked, email addresses) out of
15// the session transcript. Everything that decides what a secret is lives in
16// ./detect.ts; this file only wires it to the events where text enters the
17// conversation, and to the tools where a placeholder must become real again.
18//
19// Every hook fails closed. A hook that throws or runs past its budget is
20// skipped by the engine, and for a redactor that would mean the raw text goes
21// through. So each one has a `.catch` that withholds instead.
22
23// The scan is linear, but a string past this size could still run past the
24// hook's 10 s budget. It is withheld rather than passed on unscanned.
25const SCAN_CAP = 16_000_000
26
27const WITHHELD =
28  'transcript-redactor could not scan this result in time, so it was withheld. Narrow the output (head, grep, offset) and retry.'
29const BLOCK_WITHHELD = '[transcript-redactor withheld this text: the scan failed]'
30
31// Tools whose answer carries live placeholders, so they are not turned into
32// quoted (LITERAL) ones: the subagent tools, whose answer holds placeholders
33// the subagent used. A mod built on this one can add its own tool here.
34export const LIVE_TAG_TOOLS = new Set(['Agent', 'Task'])
35
36// The name on the toast and the tool row, and what two refusals tell the
37// model to do instead. A mod built on this one sets its own.
38export const brand = {
39  name: 'transcript-redactor',
40  // A value holding shell characters can't go into a command as itself
41  shellHint: 'Write it to a file with the Write tool and have the command read that file instead.',
42  // A placeholder the vault no longer holds stands for nothing
43  staleHint: 'Read the value again where it came from to get a fresh placeholder.',
44}
45
46// What a placeholder stands for, by name (a Keychain item, a .env variable),
47// where a mod built on this one knows it. The approval prompt lists these.
48export const tagNames = new Map<string, string>()
49
50// The labels of the approval prompt (tool.check, below)
51const ALLOW = 'Allow'
52const DENY = 'Deny'
53
54// Permission modes in which a call Claude Code would ask about must not run on
55// this file's word. dontAsk refuses what no rule allows, with no dialog, so
56// core's own verdict stands. Plan mode (nothing runs yet) is refused outright
57// (tool.check, below): its ask would open Claude Code's dialog, real values in it.
58const HOLD_MODES = new Set(['dontAsk'])
59// The modes in which, ownPrompt off, such a call is allowed. A mode not seen
60// yet, or one not named here, gets the prompt instead.
61const ALLOW_MODES = new Set(['default', 'acceptEdits', 'auto', 'bypassPermissions'])
62
63const SECRET_TAGS = /\[REDACTED-SECRET-[0-9a-f]{8}\]/g
64
65// The words of an errored call's answer, as the model read them, without the
66// wrapper a refusal is given again
67export function errorText(text: unknown, result: unknown): string {
68  const s = typeof text === 'string' ? text : typeof result === 'string' ? result.replace(/^Error: /, '') : 'The call failed.'
69  const m = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(s)
70  return m ? m[1]! : s
71}
72
73// The placeholders in a call's input that restore() can't resolve: minted
74// before this mod last loaded (the vault lives in memory only), or never
75// minted at all. Left in, one reaches the tool as its own text. One that
76// already sits in the file the call edits is that file's text (a placeholder
77// printed into it once), so it can still be edited away.
78async function staleTags(e: Record<string, unknown>, read: (path: string) => Promise<unknown>): Promise<string[]> {
79  const found = new Set<string>()
80  const look = (s: string): string => {
81    for (const t of s.match(SECRET_TAGS) ?? []) if (restore(t) === t) found.add(t)
82    return s
83  }
84  // A command's description is never run
85  walk(e.tool === 'Bash' ? { command: e.command } : argsOf(e), look)
86  const path = e.file_path ?? e.notebook_path
87  if (found.size > 0 && e.tool !== 'Bash' && typeof path === 'string') {
88    let text = ''
89    try {
90      const got = await read(path)
91      if (typeof got === 'string') text = got
92    } catch {}
93    for (const t of [...found]) if (text.includes(t)) found.delete(t)
94  }
95  return [...found]
96}
97
98// A tool call's own arguments: what the permission check reads (`{ command }`
99// for Bash), without the call's envelope
100function argsOf(e: Record<string, unknown>): Record<string, unknown> {
101  const { tool: _tool, tool_use_id: _id, agentId: _agent, requestMeta: _meta, consent: _consent, ...args } = e
102  return args
103}
104
105const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
106
107// Attachments that carry text from outside the engine's own prose: a file,
108// a diff of a file, a queued prompt. These get the full scrub, emails too.
109const OUTSIDE_TEXT = new Set(['file', 'edited_text_file', 'queued_command'])
110
111type Budget = { budget: { remainingMs: number } }
112
113// Throws before a scan that cannot finish: the `.catch` then withholds.
114function guard(text: string, next: Budget): void {
115  if (text.length > SCAN_CAP) throw new Error('transcript-redactor: text too large to scan')
116  const needMs = 300 + text.length / 4000
117  if (next.budget.remainingMs < needMs) throw new Error('transcript-redactor: out of time')
118}
119
120// A Bash command that writes text into git history or posts it to GitHub.
121// Its placeholders stay placeholders: a commit message or an issue body that
122// quotes one must not carry the real key off the machine. The whole command
123// is left as written, so a step that needs the key goes in its own call.
124const PUBLISHES =
125  /\bgit\b(?:[ \t]+-[cC][ \t]+\S+|[ \t]+--?[\w-]+(?:=\S+)?)*[ \t]+(?:commit|tag|notes|send-email)\b|\bgh[ \t]+(?:issue|pr|release|gist|discussion|api|repo[ \t]+(?:create|edit))\b/
126
127// A restored value goes into Bash as shell source. One holding any of these
128// (quotes, $, backticks, separators, globs) could run as code there, so it is
129// never put into a command. Most keys are letters, digits and -_.+/=:
130const SHELL_ACTIVE = /[\s$`"'\\;&|<>()*?[\]{}#~]|[\x00-\x1f\x7f]/
131
132// @-mentioned files the engine attaches as media or a page reference, never
133// as text. When one is too large for $.fs.read it holds no text to check.
134const MEDIA_FILE = /\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|pdf)$/i
135
136function looksBinary(text: string): boolean {
137  const head = text.slice(0, 8192)
138  return head.includes('\u0000') || (head.match(/�/g)?.length ?? 0) > 8
139}
140
141export const register: Register = (on, options) => {
142  const cfg = makeConfig(options as Record<string, unknown>)
143
144  const scrub = (text: string): string => {
145    let out = text
146    if (cfg.secrets) out = scrubSecrets(out, cfg)
147    if (cfg.pii) out = scrubPii(out, cfg)
148    return out
149  }
150  const secretsOnly = (text: string): string => (cfg.secrets ? scrubSecrets(text, cfg) : text)
151  // An `allowUrlHosts` entry that is not a host name is said once, not dropped silently.
152  let warnedHosts = cfg.allowUrlRejected.length === 0
153
154  // Counts the values hidden, not the placeholders that stand for quoted text.
155  const countHidden = (tags: Set<string>): number => [...tags].filter((t) => !t.startsWith('[REDACTED-LITERAL-')).length
156
157  // ownApproval (on unless set off): the permission check of a call this file
158  // filled with real values is answered on the call as Claude wrote it, so the
159  // real values never reach the approval dialog or the auto-mode classifier.
160  const ownApproval = ![false, 'false'].includes((options as Record<string, unknown>).ownApproval as never)
161  // ownPrompt (off unless set on): such a call that the user's rules don't
162  // decide gets this file's own Allow/Deny prompt. Off, it is allowed.
163  const ownPrompt = [true, 'true'].includes((options as Record<string, unknown>).ownPrompt as never)
164  // The calls running now with real values put in, by tool_use_id: the call
165  // as Claude wrote it (placeholders and all) and the placeholders put back.
166  const filled = new Map<string, { input: Record<string, unknown>; tags: string[] }>()
167
168  // 1. What the user types. A pasted key never reaches the model as itself,
169  //    and neither does the context a hook above attached to the prompt.
170  on('prompt.submit', async ($, e, next) => {
171    const { value, tags } = collect(() => {
172      guard(e.text, next)
173      return { text: scrub(e.text), context: e.context?.map((c) => (guard(c, next), scrub(c))) }
174    })
175    const n = countHidden(tags)
176    if (!warnedHosts) {
177      warnedHosts = true
178      $.ui.toast(`${brand.name}: allowUrlHosts ignored ${cfg.allowUrlRejected.join(', ')} (not a host name)`)
179    }
180    if (n > 0 && cfg.notify) $.ui.toast(`${brand.name}: hid ${n} value(s) from the prompt`)
181    if (tags.size === 0) return next(e)
182    return next({ ...e, text: value.text, ...(value.context ? { context: value.context } : {}) })
183  }).catch(($, e, next) => (next.called ? next(e) : { drop: 'transcript-redactor failed to scan this prompt, so it was not sent.' }))
184
185  // 2. Every tool call. On the way in, a placeholder becomes its real value,
186  //    but only for a tool that runs on this machine. On the way out, the
187  //    result, the text the model reads and any attached context are scanned.
188  on('tool.call', async ($, e, next) => {
189    const command = (e as { command?: unknown }).command
190    const publishes = e.tool === 'Bash' && typeof command === 'string' && PUBLISHES.test(command)
191    const isLocal = cfg.restore && cfg.restoreTools.has(e.tool) && !publishes
192    // A placeholder the vault no longer holds would run as its own text, with
193    // no error: the call is refused before anything runs
194    const stale = isLocal ? await staleTags(e as Record<string, unknown>, (path) => $.fs.read(path)) : []
195    if (stale.length > 0) {
196      const one = stale.length === 1
197      return {
198        deny:
199          `${brand.name}: ${stale.join(', ')} ${one ? 'is a placeholder' : 'are placeholders'} ${brand.name} no longer holds: ` +
200          `${one ? 'it is' : 'they are'} from before ${brand.name} last reloaded, so the call would have used the placeholder text itself. ` +
201          `Nothing ran. ${brand.staleHint}`,
202      }
203    }
204    // The real values this call was handed. Whatever the tool prints of them
205    // comes back hidden, even bare and short, where no detector would fire.
206    const handed = new Set<string>()
207    // The placeholders put back, for the permission check (tool.check, below)
208    const used = new Set<string>()
209    const restoreTracked = (s: string): string =>
210      restore(s) === s
211        ? s
212        : s.replace(/\[REDACTED-SECRET-[0-9a-f]{8}\]/g, (t) => {
213            const v = restore(t)
214            if (v !== t) {
215              handed.add(v)
216              used.add(t)
217            }
218            return v
219          })
220    const rehideHanded = (text: string): string => {
221      let out = text
222      for (const v of [...handed].sort((a, b) => b.length - a.length)) if (v.length >= 4 && out.includes(v)) out = out.split(v).join(mint('SECRET', v))
223      return out
224    }
225    const input = isLocal ? (walk({ ...e }, restoreTracked) as typeof e) : e
226    if (e.tool === 'Bash' && [...handed].some((v) => SHELL_ACTIVE.test(v))) {
227      return {
228        deny: `${brand.name}: a key in this command holds a space, a quote or another character the shell reads, so it was not put into the command, where the shell would change it. Nothing ran. ${brand.shellHint}`,
229      }
230    }
231    // The permission check runs inside next(): it is told of this call until
232    // the call ends or is refused
233    const id = e.tool_use_id
234    const isFilled = ownApproval && used.size > 0 && typeof id === 'string'
235    if (isFilled) filled.set(id, { input: argsOf(e), tags: [...used] })
236    let r: Awaited<ReturnType<typeof next>>
237    try {
238      r = await next(input)
239    } finally {
240      if (isFilled) filled.delete(id)
241    }
242    if (r.deny !== undefined) {
243      // A refusal from beneath may quote the input it refused, real values
244      // and all, and the model reads it as the tool's answer.
245      const d = collect(() => (guard(r.deny, next), rehideHanded(scrub(escapeLiteralTags(r.deny)))))
246      return d.tags.size === 0 ? r : { deny: d.value }
247    }
248
249    const escape = !LIVE_TAG_TOOLS.has(e.tool)
250    const st: WalkState = { incomplete: false }
251    // The result and the text often hold the same string: scan it once.
252    const memo = new Map<string, string>()
253    const clean = (s: string): string => {
254      const done = memo.get(s)
255      if (done !== undefined) return done
256      guard(s, next)
257      const out = rehideHanded(scrub(escape ? escapeLiteralTags(s) : s))
258      memo.set(s, out)
259      return out
260    }
261    const { value, tags } = collect(() => ({
262      result: walk(r.result, clean, st),
263      text: r.text === undefined ? undefined : clean(r.text),
264      context: r.context?.map(clean),
265    }))
266    if (tags.size === 0) return r
267    // Something was hidden, and part of the result could not be rewritten:
268    // that part may hold the same secret, so the whole result is withheld.
269    if (st.incomplete) return { deny: 'transcript-redactor: a secret sits in a part of this result it cannot rewrite (too deep, or not plain data). Narrow the call and retry.' }
270
271    const n = countHidden(tags)
272    if (cfg.notify && n > 0) $.ui.notice(e.tool_use_id, `${brand.name}: hid ${n} value(s)`)
273    // An errored call can be answered only as core answered it or as a
274    // refusal: any other answer is checked against the tool's output shape,
275    // which an error's text never fits ("expected object, received string"),
276    // and that check's failure replaces the answer. So a rewritten error goes
277    // back as a refusal holding it. (A refusal carries no context; core gives
278    // an errored call none.)
279    if (r.isError) return { deny: errorText(value.text, value.result) }
280    const context = value.context ? { context: value.context } : {}
281    return { result: value.result, ...context } as typeof r
282  }).catch(() =>
283    // Nothing ran yet: the call is refused. The tool answered: its answer is
284    // withheld (an errored one can only be withheld this way, as above).
285    ({ deny: WITHHELD }),
286  )
287
288  // 2b. The permission check of a call that tool.call above filled with real
289  //     values. Claude Code checks the call as it will run, real values and
290  //     all, so its dialog would show them and the auto-mode classifier would
291  //     read them. Only for such a call (a query, with no tool_use_id, passes):
292  //       1. Core's verdict first. Its allow or deny stands.
293  //       2. On ask: the user's own rules, asked about the call as written.
294  //       3. Plan mode: refused, so Claude Code's dialog never opens with
295  //          the real values in it. The step waits for the plan to end.
296  //       4. No one to ask (-p): core's verdict goes on unchanged. In
297  //          dontAsk mode too: the mode settles it as it would any call.
298  //       5. ownPrompt off, in a mode known to allow asking: allowed, so the
299  //          user isn't asked about every command that uses a key. The
300  //          auto-mode classifier then doesn't read the command at all.
301  //       6. Otherwise (ownPrompt on, or the mode not known yet): the person,
302  //          shown the call as written, picks Allow or Deny. Anything but
303  //          Allow (a dismissal, typed text) is a deny.
304  //     A hook that fails leaves core's verdict.
305  if (ownApproval && cfg.restore && cfg.restoreTools.size > 0) {
306    // The permission mode each loop runs in, by agentId ('' for the main
307    // one), as the last classic hook event from that loop gave it: tool.check
308    // itself isn't told the mode. SubagentStart and SubagentStop are left
309    // out, since they may give the parent's.
310    const modes = new Map<string, string>()
311    const noteMode = (e: unknown): void => {
312      const { permission_mode: mode, agent_id: agent } = e as { permission_mode?: unknown; agent_id?: unknown }
313      if (typeof mode === 'string') modes.set(typeof agent === 'string' ? agent : '', mode)
314    }
315    on('classic.SessionStart', ($, e, next) => (noteMode(e), next(e)))
316    on('classic.UserPromptSubmit', ($, e, next) => (noteMode(e), next(e)))
317    on('classic.PostToolUse', ($, e, next) => (noteMode(e), next(e)))
318    on('classic.PostToolUseFailure', ($, e, next) => (noteMode(e), next(e)))
319
320    const tools = new RegExp(`^(?:${[...cfg.restoreTools].map(escapeRe).join('|')})$`)
321    on('tool.check', { tool: tools }, async ($, e, next) => {
322      const call = e.tool_use_id === undefined ? undefined : filled.get(e.tool_use_id)
323      const core = await next(e)
324      if (!call || core.decision !== 'ask') return core
325      try {
326        const rules = await $.tool.check({ tool: e.tool, input: call.input })
327        if (rules.decision === 'allow' || rules.decision === 'deny') return rules
328      } catch {}
329      const mode = modes.get(e.agentId ?? '')
330      if (mode === 'plan') {
331        const what = e.tool === 'Bash' ? 'a command' : 'a file change'
332        return { decision: 'deny', reason: `${brand.name}: ${what} that uses a key waits until plan mode ends. Put this step in the plan instead of running it.` }
333      }
334      if ((await $.session.surfaces()).length === 0) return core
335      if (mode !== undefined && HOLD_MODES.has(mode)) return core
336      const many = call.tags.length > 1
337      if (!ownPrompt && mode !== undefined && ALLOW_MODES.has(mode)) {
338        return { decision: 'allow', reason: `${brand.name}: allowed; it fills in ${many ? 'the keys' : 'the key'} from ${many ? 'their placeholders' : 'its placeholder'} only while the call runs` }
339      }
340      const names = call.tags.map((t) => (tagNames.has(t) ? `${tagNames.get(t)} (${t})` : t)).join(', ')
341      const isBash = e.tool === 'Bash' && typeof call.input.command === 'string'
342      const shown = isBash ? String(call.input.command) : JSON.stringify(call.input, null, 2)
343      const how = isBash
344        ? `The ${many ? 'keys are' : 'key is'} filled in only while it runs, and no one sees ${many ? 'them' : 'it'}. Allow this command?`
345        : `The ${many ? 'keys go' : 'key goes'} into the file; the chat only ever shows the placeholder. Allow this ${e.tool}?`
346      const question = `${shown}\n\nIt uses ${names}. ${how}`
347      let answer = ''
348      try {
349        answer = await $.ui.ask(question, { options: [ALLOW, DENY], header: brand.name.slice(0, 12) })
350      } catch {}
351      if (answer === ALLOW) return { decision: 'allow', reason: `${brand.name}: the user allowed this call in its approval prompt` }
352      // No placeholder in the reason: the model reads it as the call's error
353      return { decision: 'deny', reason: `${brand.name}: the user did not allow this call. Ask them what to do; don't retry it in another form.` }
354    }).catch(($, e, next) => next(e))
355  }
356
357  // 3. The blocks attached to the first message (CLAUDE.md and friends).
358  //    Secrets only: the email in there is the user's own, and the engine
359  //    puts it there so the model knows who it is talking to.
360  on('prompt.context', async ($, e, next) => {
361    const r = await next(e)
362    if (!cfg.secrets) return r
363    const blocks: PromptContextBlock[] = r.blocks.map((b) => {
364      guard(b.text, next)
365      return { ...b, text: secretsOnly(b.text) }
366    })
367    // The raw files behind a rewritten CLAUDE.md would carry the key on to
368    // the hooks above, so they are left out once any block changed.
369    if (blocks.every((b, i) => b.text === r.blocks[i].text)) return r
370    return { blocks }
371  }).catch(async ($, e, next) => {
372    // Keep the blocks, so the model sees that something was held back.
373    const r = await next(e)
374    return { blocks: r.blocks.map((b) => ({ ...b, text: BLOCK_WITHHELD })) }
375  })
376
377  // 4. What the engine injects on its own after the first message: a file
378  //    named with @, a diff of a file that changed on disk, a nested
379  //    CLAUDE.md, a settings hook's context. None of it passes tool.call.
380  on('prompt.attachment', async ($, e, next) => {
381    const r = await next(e)
382    if (r.text == null) return r
383    const text = r.text
384    guard(text, next)
385    const isOutside = e.origin.kind !== 'engine' || OUTSIDE_TEXT.has(e.type)
386    const isFile = e.type === 'file' || e.type === 'edited_text_file'
387    const out = isOutside ? scrub(isFile ? escapeLiteralTags(text) : text) : secretsOnly(text)
388    return out === text ? r : { text: out }
389  }).catch(() => ({ text: BLOCK_WITHHELD }))
390
391  // 5. A file named with @ is read with no tool call, and its raw text is
392  //    stored in the transcript whatever the model is shown. So a file that
393  //    holds a secret is refused here, before it is read, and the model is
394  //    told to use the Read tool, whose result is scrubbed.
395  on('prompt.mention', async ($, e, next) => {
396    if (!cfg.secrets) return next(e)
397    let text: string
398    try {
399      const read = await $.fs.read(e.path)
400      if (typeof read !== 'string') return next(e)
401      text = read
402    } catch {
403      // Missing: the engine attaches nothing. An image or a PDF too large to
404      // read is attached as media, not text. A text file that is present
405      // but unreadable (too large, say) cannot be checked, so it is refused.
406      if (!(await $.fs.exists(e.path))) return next(e)
407      if (MEDIA_FILE.test(e.path)) return next(e)
408      return { deny: 'transcript-redactor: could not check this file for secrets; ask Claude to Read it instead' }
409    }
410    if (looksBinary(text)) return next(e)
411    guard(text, next)
412    const { tags } = collect(() => scrubSecrets(text, cfg))
413    if (tags.size > 0) return { deny: 'transcript-redactor: this file holds a secret; ask Claude to Read it instead, so the value is replaced by a placeholder' }
414    return next(e)
415  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'transcript-redactor: could not check this file for secrets' }))
416
417  // 6. A subagent's task. The model only ever holds placeholders, and the
418  //    Agent tool gets no restore, so this is a second layer: a value that
419  //    slipped into the prompt some other way still reaches the subagent as
420  //    a placeholder. A workflow agent's prompt cannot be rewritten, only
421  //    refused, so one that holds a secret is not started.
422  on('agent.spawn', async ($, e, next) => {
423    if (!cfg.secrets) return next(e)
424    if (e.workflow) {
425      guard(e.prompt, next)
426      const { tags } = collect(() => secretsOnly(e.prompt))
427      if (tags.size === 0) return next(e)
428      return { deny: "transcript-redactor: this workflow agent's prompt holds a secret, and a workflow prompt cannot be rewritten. Pass the value by a file or an env var instead." }
429    }
430    guard(e.prompt, next)
431    const prompt = secretsOnly(e.prompt)
432    const description = secretsOnly(e.description)
433    return prompt === e.prompt && description === e.description ? next(e) : next({ ...e, prompt, description })
434  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'transcript-redactor failed to scan this task, so the subagent was not started.' }))
435}
436
hooks/detect.ts 1650 lines
1// The pure half of transcript-redactor: the config, the vault, the detectors,
2// and the walkers. Nothing in here touches `$` or registers a hook, so another
3// mod can vendor this file as it is and the tests can run it under plain node.
4//
5// The value is not deleted. It is put in a vault that lives in this module,
6// in memory, for the session, and the transcript gets a placeholder in its
7// place: `[REDACTED-SECRET-1a2b3c4d]`. Within one session the same value always
8// mints the same placeholder, so the model can still tell one customer from
9// another, and the placeholder is swapped back for the real value on the way
10// into a tool that runs on this machine, so a `Bash` command or a `Write` still
11// carries the real key.
12//
13// Nothing is written to disk. The vault dies with the session.
14
15export type Kind = 'SECRET' | 'EMAIL' | 'IP' | 'LITERAL'
16
17export type Config = {
18  secrets: boolean
19  pii: boolean
20  emails: boolean
21  privateIps: boolean
22  restore: boolean
23  restoreTools: ReadonlySet<string>
24  notify: boolean
25  minEntropy: number
26  minLength: number
27  contextMinEntropy: number
28  allow: ReadonlySet<string>
29  allowEmails: readonly string[]
30  allowPrefix: readonly string[]
31  denyPrefix: readonly string[]
32  allowUrl: AllowUrl
33  // Entries of `allowUrlHosts` that are not a host name, shown once.
34  allowUrlRejected: readonly string[]
35}
36
37// The tools that run on this machine. A placeholder is only swapped back for
38// its real value in these: a tool that hands its input to another model or to
39// the network (Agent, WebFetch, WebSearch, SendMessage, an MCP tool) gets the
40// placeholder as written.
41export const DEFAULT_RESTORE_TOOLS = 'Bash,Write,Edit,MultiEdit,NotebookEdit'
42
43// ---------------------------------------------------------------- the vault
44
45const byValue = new Map<string, string>()
46const byTag = new Map<string, string>()
47let hidden = 0
48let minted: Set<string> | null = null
49
50const TAG = /\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]/g
51const WHOLE_TAG = /^\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]$/
52
53function randomHex8(): string {
54  const bytes = new Uint8Array(4)
55  const crypto = (globalThis as { crypto?: { getRandomValues?: (values: Uint8Array) => Uint8Array } }).crypto
56  if (crypto?.getRandomValues) crypto.getRandomValues(bytes)
57  else for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256)
58  return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
59}
60
61// Swaps a value for its placeholder and remembers both. A value that already
62// holds a placeholder from an earlier pass (a key inside a URL password) is
63// stored with that placeholder put back, so one restore gives the real text.
64export function mint(kind: Kind, raw: string): string {
65  hidden++
66  const value = kind === 'LITERAL' ? raw : restore(unpark(raw))
67  const seen = byValue.get(value)
68  if (seen) {
69    if (kind === 'SECRET') remember(value)
70    minted?.add(seen)
71    return seen
72  }
73  let tag: string
74  do tag = `[REDACTED-${kind}-${randomHex8()}]`
75  while (byTag.has(tag))
76  byValue.set(value, tag)
77  byTag.set(tag, value)
78  if (kind === 'SECRET') remember(value)
79  minted?.add(tag)
80  return tag
81}
82
83// A secret is hidden again wherever it turns up later: across the session
84// when it is distinctive enough (echoable), and within the text being
85// scanned right now when it is shorter but still not a plain word.
86function remember(value: string): void {
87  const echoes = echoable(value)
88  if (echoes && !echo.has(value)) {
89    echo.add(value)
90    echoIndex = null
91  }
92  if (local && (echoes || localWorthy(value))) local.add(value)
93}
94
95// One pass, on purpose: a LITERAL placeholder gives back the placeholder text
96// it stands for, and that text must not then turn into the secret.
97export function restore(text: string): string {
98  if (byTag.size === 0 || !text.includes('[REDACTED-')) return text
99  return text.replace(TAG, (tag) => byTag.get(tag) ?? tag)
100}
101
102// Hides a value the caller knows is secret wherever it turns up for the rest
103// of the session, even one too short or too plain for the echo rule (a word
104// password from .env). Values under PREFIX characters can't be indexed.
105export function keepSecret(value: string): string {
106  const tag = mint('SECRET', value)
107  if (value.length >= PREFIX && !echo.has(value)) {
108    echo.add(value)
109    echoIndex = null
110  }
111  return tag
112}
113
114// How many times a value has been hidden this session. A hook reads it before
115// and after a scrub to learn whether anything changed.
116export function hiddenCount(): number {
117  return hidden
118}
119
120// Runs `fn` and hands back every placeholder it minted or reused, so a hook
121// can count the values it hid without counting one value twice.
122export function collect<T>(fn: () => T): { value: T; tags: Set<string> } {
123  const outer = minted
124  const tags = new Set<string>()
125  minted = tags
126  try {
127    return { value: fn(), tags }
128  } finally {
129    minted = outer
130    if (outer) for (const t of tags) outer.add(t)
131  }
132}
133
134// A placeholder read back from a file or a log is quoted text, not a handle.
135// Left alone it would turn into the real secret the next time the model
136// copied that text into a command. So a live placeholder found in a tool's
137// output is swapped for a LITERAL one, which restores to the placeholder text
138// and nothing more. Run this before the detectors, so a key the tool printed
139// still comes back as its live placeholder.
140export function escapeLiteralTags(text: string): string {
141  if (byTag.size === 0 || !text.includes('[REDACTED-')) return text
142  return text.replace(TAG, (t) => (byTag.has(t) && !t.startsWith('[REDACTED-LITERAL-') ? mint('LITERAL', t) : t))
143}
144
145// ------------------------------------------------------- known value echoes
146
147// A value hidden once because of where it sat (after `API_KEY=`, inside a
148// URL) is hidden again wherever it turns up, even bare, where no detector
149// would fire on it: a tool that echoes a restored key must not print it.
150const echo = new Set<string>()
151let echoIndex: EchoIndex | null = null
152// The values minted during the current scan, to hide at their other spots.
153let local: Set<string> | null = null
154
155// Short or all-letter values (`postgres`) are left out: hiding every later
156// `postgres` in unrelated output would make the session unreadable.
157function echoable(value: string): boolean {
158  if (countsUp(value)) return false
159  // A slug or a dated name hidden once beside a key name is not worth hiding
160  // everywhere: it would turn every path that holds it into a placeholder.
161  if (value.split(/[^A-Za-z0-9]+/).every((p) => p === '' || isSlugPart(p))) return false
162  if (value.includes('\n')) return value.length >= 40
163  return value.length >= 20 || (value.length >= 10 && /[0-9]/.test(value) && /[A-Za-z]/.test(value))
164}
165
166// A value too short to hide across the session is still hidden at its other
167// spots in the same text, unless it is a plain word or slug (`postgres`
168// hidden once in a URL must not turn every `postgres://` into a placeholder).
169function localWorthy(value: string): boolean {
170  if (value.length < PREFIX || value.length > 400 || value.includes('\n') || countsUp(value)) return false
171  if (value.split(/[^A-Za-z0-9]+/).every((p) => p === '' || isSlugPart(p))) return false
172  return /[0-9]/.test(value) || /[^A-Za-z0-9]/.test(value) || value.length >= 12
173}
174
175// The known values are found with a rolling hash over their first PREFIX
176// characters, so the cost of the scan does not grow with the vault. A regex
177// of every value joined by `|` took 40 s on 200 KB once a long session had
178// hidden a few thousand values.
179const PREFIX = 8
180// Under each prefix the values are kept by length, longest first, so a
181// prefix that thousands of values share costs one set lookup per length,
182// not a walk of the whole list.
183type ByLength = { lengths: number[]; values: Set<string> }
184type EchoIndex = { hashes: Set<number>; byPrefix: Map<string, ByLength>; top: number }
185
186function prefixHash(text: string, at: number): number {
187  let h = 0
188  for (let i = 0; i < PREFIX; i++) h = (Math.imul(h, 31) + text.charCodeAt(at + i)) >>> 0
189  return h
190}
191
192function buildEchoIndex(values: Iterable<string>): EchoIndex {
193  const byPrefix = new Map<string, ByLength>()
194  for (const v of values) {
195    const p = v.slice(0, PREFIX)
196    let entry = byPrefix.get(p)
197    if (!entry) byPrefix.set(p, (entry = { lengths: [], values: new Set() }))
198    if (!entry.lengths.includes(v.length)) entry.lengths.push(v.length)
199    entry.values.add(v)
200  }
201  for (const entry of byPrefix.values()) entry.lengths.sort((a, b) => b - a)
202  const hashes = new Set<number>()
203  for (const p of byPrefix.keys()) hashes.add(prefixHash(p, 0))
204  // 31 to the power PREFIX - 1, to take the oldest character out of the hash.
205  let top = 1
206  for (let i = 0; i < PREFIX - 1; i++) top = Math.imul(top, 31) >>> 0
207  return { hashes, byPrefix, top }
208}
209
210function rehideKnown(text: string): string {
211  if (echo.size === 0 || text.length < PREFIX) return text
212  return rehideWith(text, (echoIndex ??= buildEchoIndex(echo)))
213}
214
215// The values minted in one scan, hidden at their other spots in it. A few
216// values are found faster one by one than by the rolling hash.
217function rehideLocal(text: string, values: Set<string>): string {
218  if (values.size > 64) return rehideWith(text, buildEchoIndex(values))
219  let out = text
220  for (const v of [...values].sort((a, b) => b.length - a.length)) if (out.includes(v)) out = out.split(v).join(mint('SECRET', v))
221  return out
222}
223
224function rehideWith(text: string, ix: EchoIndex): string {
225  if (text.length < PREFIX) return text
226  const parts: string[] = []
227  let last = 0
228  let h = prefixHash(text, 0)
229  for (let i = 0; i + PREFIX <= text.length; ) {
230    if (ix.hashes.has(h)) {
231      const entry = ix.byPrefix.get(text.slice(i, i + PREFIX))
232      let found: string | undefined
233      if (entry) {
234        for (const n of entry.lengths) {
235          const v = text.slice(i, i + n)
236          if (v.length === n && entry.values.has(v)) {
237            found = v
238            break
239          }
240        }
241      }
242      if (found) {
243        parts.push(text.slice(last, i), mint('SECRET', found))
244        i += found.length
245        last = i
246        if (i + PREFIX <= text.length) h = prefixHash(text, i)
247        continue
248      }
249    }
250    if (i + PREFIX >= text.length) break
251    h = (Math.imul((h - Math.imul(text.charCodeAt(i), ix.top)) >>> 0, 31) + text.charCodeAt(i + PREFIX)) >>> 0
252    i++
253  }
254  if (last === 0) return text
255  parts.push(text.slice(last))
256  return parts.join('')
257}
258
259// ------------------------------------------------------------------ parking
260
261// Some spans are known not to be secrets and are big enough to drown the
262// transcript in placeholders: an image in a data: URI, a lockfile hash, a
263// thinking-block signature, a base64 attachment. They are parked behind a
264// marker for the scan and put back after it. The marker is a character the
265// text does not contain; if every candidate is taken, nothing is parked and
266// the scan simply hides more.
267type Parked = { mark: string; spans: string[]; re: RegExp }
268let parked: Parked | null = null
269const MARKS = ['\u0000', '\uE000', '\uE001', '\uFDD0']
270
271function startParking(text: string): Parked | null {
272  const mark = MARKS.find((m) => !text.includes(m))
273  return mark ? { mark, spans: [], re: new RegExp(`${mark}(\\d+)${mark}`, 'g') } : null
274}
275
276function park(span: string): string {
277  if (!parked) return span
278  parked.spans.push(span)
279  return `${parked.mark}${parked.spans.length - 1}${parked.mark}`
280}
281
282function unpark(text: string): string {
283  const p = parked
284  if (!p || !text.includes(p.mark)) return text
285  let out = text
286  for (let i = 0; i < 3 && out.includes(p.mark); i++) out = out.replace(p.re, (_m, n: string) => p.spans[Number(n)] ?? '')
287  return out
288}
289
290// ------------------------------------------------------------- small tools
291
292// Bits per character. Counted in a reused array for plain ASCII, since the
293// scan calls this for every candidate token.
294const COUNTS = new Int32Array(128)
295
296function entropy(text: string): number {
297  let ascii = true
298  for (let i = 0; i < text.length; i++) {
299    const c = text.charCodeAt(i)
300    if (c >= 128) {
301      ascii = false
302      break
303    }
304  }
305  if (!ascii) {
306    const counts = new Map<string, number>()
307    for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1)
308    let h = 0
309    for (const n of counts.values()) h -= (n / text.length) * Math.log2(n / text.length)
310    return h
311  }
312  COUNTS.fill(0)
313  for (let i = 0; i < text.length; i++) COUNTS[text.charCodeAt(i)]!++
314  let h = 0
315  for (let c = 0; c < 128; c++) {
316    const n = COUNTS[c]!
317    if (n > 0) h -= (n / text.length) * Math.log2(n / text.length)
318  }
319  return h
320}
321
322const B64_TABLE = (() => {
323  const t = new Int16Array(128).fill(-1)
324  const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
325  for (let i = 0; i < abc.length; i++) t[abc.charCodeAt(i)] = i
326  t['-'.charCodeAt(0)] = 62
327  t['_'.charCodeAt(0)] = 63
328  return t
329})()
330
331// Decodes the start of a base64 text into one character per byte. The hooks
332// run with no Buffer and no atob, so this is done by hand.
333function decodeBase64Head(text: string, max = 8192): string {
334  let out = ''
335  let acc = 0
336  let bits = 0
337  for (let i = 0; i < text.length && out.length < max; i++) {
338    const c = text.charCodeAt(i)
339    if (c === 61) break
340    const v = c < 128 ? B64_TABLE[c]! : -1
341    if (v < 0) continue
342    acc = ((acc << 6) | v) & 0xffffff
343    bits += 6
344    if (bits >= 8) {
345      bits -= 8
346      out += String.fromCharCode((acc >> bits) & 0xff)
347    }
348  }
349  return out
350}
351
352// Ranges of the text that the token scan skips. A `soft` range is skipped
353// only when no key name stands in front of the token.
354type Span = [start: number, end: number, soft: boolean]
355
356class Spans {
357  private list: Span[] = []
358  private i = 0
359  add(start: number, end: number, soft = false): void {
360    if (end > start) this.list.push([start, end, soft])
361  }
362  seal(): this {
363    this.list.sort((a, b) => a[0] - b[0])
364    return this
365  }
366  // Offsets are asked in ascending order, so a cursor keeps this linear.
367  at(offset: number): 'hard' | 'soft' | null {
368    const l = this.list
369    while (this.i < l.length && l[this.i]![1] <= offset) this.i++
370    let found: 'hard' | 'soft' | null = null
371    for (let j = this.i; j < l.length && l[j]![0] <= offset; j++) {
372      if (l[j]![1] > offset) {
373        if (!l[j]![2]) return 'hard'
374        found = 'soft'
375      }
376    }
377    return found
378  }
379}
380
381// ------------------------------------------------------------ secret tests
382
383// Escapes that end in a letter or digit and may sit right before a key:
384// quoted-printable `=3D`, URL `%3D`, and the `\n` or `"` of escaped JSON.
385const ESC = String.raw`[%=]3D|\\[nrtfbv]|\\u[0-9A-Fa-f]{4}`
386const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
387
388// A vendor prefix only counts at the start of a word, or right after one of
389// those escapes. Without this, `task-runner-for-the-build` reads as `sk-`.
390// The prefix is written first and the boundary checked behind it, so the
391// regex engine can jump between occurrences of the prefix instead of
392// testing the boundary at every character of the text.
393function word(prefix: string, body: string, edge = 'A-Za-z0-9'): string {
394  return String.raw`${prefix}(?<=(?:^|[^${edge}]|${ESC})${prefix})${body}`
395}
396
397// Shapes that belong to one vendor and mean one thing. These are hidden on
398// sight, whatever their entropy. Each has a hint, a cheap test that the text
399// could hold one, so a 10 MB log pays only for the shapes it might contain.
400// Every quantifier is bounded: an open `{n,}` over a run of millions of
401// characters overflows the regex stack.
402type Shape = { hint: string | RegExp; re: string }
403const VENDOR_SHAPES: readonly Shape[] = [
404  { hint: 'sk-ant-', re: word('sk-ant-', '[A-Za-z0-9_-]{16,4096}') },
405  // A real OpenAI key has a digit or a capital early on; a kebab word does not.
406  { hint: 'sk-', re: word('sk-', '(?=[a-z_-]{0,200}[0-9A-Z])[A-Za-z0-9_-]{20,4096}') },
407  { hint: /gh[pousr]_/, re: word('gh[pousr]_', '[A-Za-z0-9]{20,4096}') },
408  { hint: 'github_pat_', re: word('github_pat_', '[A-Za-z0-9_]{20,4096}') },
409  { hint: 'xox', re: word('xox[baprse]-', '[A-Za-z0-9-]{10,4096}') },
410  { hint: 'xapp-', re: word('xapp-', '[0-9]-[A-Za-z0-9-]{10,4096}') },
411  { hint: /A[KS]IA/, re: word('A(?:KIA|SIA)', '[0-9A-Z]{16}(?![A-Za-z0-9])') },
412  { hint: /[sr]k_(?:live|test)_/, re: word('[sr]k_(?:live|test)_', '[A-Za-z0-9]{16,4096}') },
413  { hint: 'whsec_', re: word('whsec_', '[A-Za-z0-9]{16,4096}') },
414  { hint: 'AIza', re: word('AIza', '[0-9A-Za-z_-]{30,4096}') },
415  { hint: 'ya29.', re: word(String.raw`ya29\.`, '[A-Za-z0-9_-]{20,4096}') },
416  { hint: 'npm_', re: word('npm_', '[A-Za-z0-9]{30,4096}') },
417  { hint: 'dop_v1_', re: word('dop_v1_', '[a-f0-9]{40,4096}') },
418  { hint: 'glpat-', re: word('glpat-', '[A-Za-z0-9_-]{16,4096}') },
419  { hint: 'shpat_', re: word('shpat_', '[a-f0-9]{32,4096}') },
420  { hint: 'SG.', re: word(String.raw`SG\.`, String.raw`[A-Za-z0-9_-]{16,4096}\.[A-Za-z0-9_-]{16,4096}`) },
421  { hint: 'hooks.slack.com', re: String.raw`https://hooks\.slack\.com/services/[A-Za-z0-9/+_-]{16,4096}` },
422  { hint: '/api/webhooks/', re: String.raw`https://discord(?:app)?\.com/api/webhooks/[0-9]{1,30}/[A-Za-z0-9_-]{16,4096}` },
423  // Prefixes built from words, which the name test would otherwise excuse.
424  { hint: 'vercel_blob_rw_', re: word('vercel_blob_rw_', '[A-Za-z0-9]{16}_[A-Za-z0-9]{30}') },
425  { hint: 'tskey-', re: word('tskey-', '[a-z]{1,20}-[A-Za-z0-9_-]{20,4096}') },
426  { hint: 'pscale_', re: word('pscale_', '(?:tkn|pw|oauth)_[A-Za-z0-9_-]{32,4096}') },
427  { hint: 'pdl_', re: word('pdl_', '(?:live|sdbx)_apikey_[A-Za-z0-9_-]{20,4096}') },
428  { hint: 'pypi-AgE', re: word('pypi-AgE', '[A-Za-z0-9_-]{50,4096}') },
429  { hint: 'sntrys_eyJ', re: word('sntrys_eyJ', '[A-Za-z0-9+/=_-]{50,4096}') },
430  { hint: 'pcsk_', re: word('pcsk_', '[A-Za-z0-9_-]{40,4096}') },
431  { hint: 'HRKU-AA', re: word('HRKU-AA', '[A-Za-z0-9_-]{40,4096}') },
432  { hint: 'dckr_', re: word('dckr_', '(?:pat|oat)_[A-Za-z0-9_-]{20,4096}') },
433  { hint: 'figd_', re: word('figd_', '[A-Za-z0-9_-]{30,4096}') },
434  { hint: 'GOCSPX-', re: word('GOCSPX-', '[A-Za-z0-9_-]{20,4096}') },
435  { hint: /gl(?:rt|dt|ptt|cbt|imt|ft|soat|oas|agent)-/, re: word('gl(?:rt|dt|ptt|cbt|imt|ft|soat|oas|agent)-', '[A-Za-z0-9_-]{20,4096}') },
436  { hint: 'bb_live_', re: word('bb_live_', '[A-Za-z0-9_-]{20,4096}') },
437  { hint: 'sb_secret_', re: word('sb_secret_', '[A-Za-z0-9_-]{20,4096}') },
438  { hint: 'ATATT3', re: word('ATATT3', '[A-Za-z0-9_=-]{100,4096}') },
439  // Keys whose secret half sits after a `.`, `:` or `/`.
440  { hint: 'pat', re: word('pat', String.raw`[A-Za-z0-9]{14}\.[a-f0-9]{64}`) },
441  { hint: /[st]k\.eyJ/, re: word(String.raw`[st]k\.eyJ`, String.raw`[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{20,4096}`) },
442  { hint: 'st.', re: word(String.raw`st\.`, String.raw`(?:[0-9a-f]{24}|${UUID})\.[0-9a-f]{32}\.[0-9a-f]{32}`) },
443  { hint: /\/[0-9]{8}[0-9/]*:[0-9a-f]{32}/, re: String.raw`(?<![0-9])[0-9]/[0-9]{8,30}(?:/[0-9]{8,30})?:[0-9a-f]{32}` },
444  { hint: /-[0-9a-f]{12}:[0-9a-f]{32}/, re: String.raw`(?<![0-9A-Fa-f])${UUID}:[0-9a-f]{32}` },
445  // Hugging Face tokens are letters only, so no entropy rule can see them.
446  { hint: /(?:hf|api_org)_/, re: word('(?:hf|api_org)_', '[A-Za-z0-9]{34}(?![A-Za-z0-9_])') },
447  { hint: 'ops_eyJ', re: word('ops_eyJ', '[A-Za-z0-9+/=_-]{50,4096}') },
448  { hint: /fm[12][ar]?_/, re: word('fm[12][ar]?_', '[A-Za-z0-9+/=,_-]{50,4096}') },
449  { hint: 'sig=', re: String.raw`(?<=[?&]sig=)[A-Za-z0-9%+/]{30,4096}` },
450  { hint: 'AAAAAAAAAAAAAAAAAAAAA', re: word('AAAAAAAAAAAAAAAAAAAAA', '[A-Za-z0-9]{0,200}%[A-Za-z0-9%]{30,4096}') },
451  { hint: 'Q~', re: String.raw`[A-Za-z0-9_~.-]{3}[0-9][A-Za-z0-9_~.-]Q~[A-Za-z0-9_~.-]{31,34}` },
452  // Lowercase and hex shapes that one vendor owns.
453  { hint: 'e2b_', re: word('e2b_', String.raw`[a-f0-9]{40}(?![\w-])`) },
454  { hint: 'lsv2_', re: word('lsv2_', String.raw`(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10}(?![\w-])`) },
455  { hint: /[0-9a-f]-us[0-9]/, re: String.raw`(?:(?<=${ESC})|(?<![A-Za-z0-9]))[a-f0-9]{32}-us[0-9]{1,2}(?![\w-])` },
456  { hint: /-[a-f0-9]{8}-[a-f0-9]{8}(?![\w-])/, re: String.raw`(?:(?<=${ESC})|(?<![A-Za-z0-9]))[a-f0-9]{32}-[a-f0-9]{8}-[a-f0-9]{8}(?![\w-])` },
457  { hint: /xa[ap]t-/, re: word('xa[ap]t-', String.raw`${UUID}(?![\w-])`) },
458  { hint: 'SK', re: word('SK', String.raw`[0-9a-f]{32}(?![\w-])`) },
459  { hint: 'tvly-', re: word('tvly-', '(?:dev-|prod-)?[A-Za-z0-9]{32,4096}') },
460  { hint: 'tr_', re: word('tr_', '(?:dev|prod|stg|pat)_[A-Za-z0-9]{20,4096}', 'A-Za-z0-9_') },
461  { hint: ':AA', re: String.raw`(?<![0-9])[0-9]{8,10}:AA[A-Za-z0-9_-]{33}(?![A-Za-z0-9_-])` },
462  { hint: /a[ks]-/, re: word('a[ks]-', '[A-Za-z0-9]{22}(?![A-Za-z0-9_-])', 'A-Za-z0-9_-') },
463  // Keys that reuse a Stripe id prefix: Resend `re_`, Stripe client secrets.
464  { hint: 're_', re: word('re_', '[A-Za-z0-9]{8}_[A-Za-z0-9]{24}(?![A-Za-z0-9_])', 'A-Za-z0-9_') },
465  { hint: '_secret_', re: word('(?:pi|seti)_', '[A-Za-z0-9]{14,64}_secret_[A-Za-z0-9]{16,4096}', 'A-Za-z0-9_') },
466  // A Klaviyo private key is `pk_` and 34 alnum. Stripe and Clerk publishable
467  // keys always carry `live_` or `test_` after `pk_`, so they do not match.
468  { hint: 'pk_', re: word('pk_', '[A-Za-z0-9]{34}(?![A-Za-z0-9_])', 'A-Za-z0-9_') },
469  // An age identity (SOPS reads it from keys.txt) is upper case and digits.
470  { hint: 'AGE-SECRET-KEY-1', re: word('AGE-SECRET-KEY-1', '[0-9A-Z]{58}') },
471  { hint: 'AGE-PLUGIN-', re: word('AGE-PLUGIN-[A-Z0-9]{1,40}-1', '[0-9A-Z]{20,4096}') },
472  // Tokens that are not mixed case, so no entropy rule sees them alone.
473  { hint: /NR(?:AK|II|JS|AA|RA)-/, re: word('NR(?:AK|II|JS|AA|RA)-', '[A-Za-z0-9_-]{27,40}') },
474  { hint: 'dt0c01.', re: word(String.raw`dt0c01\.`, String.raw`[A-Z0-9]{24}\.[A-Z0-9]{64}`) },
475  { hint: /pat-(?:na|eu|ap)[0-9]-/, re: word('pat-(?:na|eu|ap)[0-9]-', String.raw`${UUID}(?![\w-])`) },
476  { hint: /(?:sdk|api)-[0-9a-f]{8}-/, re: word('(?:sdk|api)-', String.raw`${UUID}(?![\w-])`) },
477  { hint: 'lin_oauth_', re: word('lin_oauth_', '[a-f0-9]{64}') },
478  { hint: 'shippo_', re: word('shippo_(?:live|test)_', '[a-f0-9]{40}') },
479  { hint: 'dapi', re: word('dapi', String.raw`[a-f0-9]{32}(?:-[0-9]{1,2})?(?![\w-])`) },
480  { hint: ':fx', re: String.raw`(?<![0-9a-f-])${UUID}:fx(?!\w)` },
481  { hint: 'access_token$', re: String.raw`access_token\$(?:production|sandbox)\$[a-z0-9]{16}\$[a-f0-9]{32}` },
482  // A Twilio auth token is plain hex; it is known by the account SID beside it.
483  { hint: /AC[0-9a-f]{32}["']?[ \t]*[,:]/, re: String.raw`(?<=\bAC[0-9a-f]{32}["']?[ \t]*[,:][ \t]*["']?)[0-9a-f]{32}(?![0-9A-Za-z])` },
484]
485
486// Each shape also takes in the rest of its token, so a match that stops at a
487// `-` or `_` inside a key cannot leave the tail of the key on screen.
488const VENDOR = VENDOR_SHAPES.map((s) => ({ hint: s.hint, re: new RegExp(`(?:${s.re})[A-Za-z0-9_-]{0,4096}`, 'g') }))
489
490function scrubVendors(text: string, cfg: Config): string {
491  let out = text
492  for (const { hint, re } of VENDOR) {
493    if (typeof hint === 'string' ? !out.includes(hint) : !hint.test(out)) continue
494    out = out.replace(re, (m) => (cfg.allow.has(m) || m.includes('[REDACTED-') ? m : mint('SECRET', m)))
495  }
496  return out
497}
498
499// A private key block, up to its END line (never past the next BEGIN, so a
500// file of BEGIN lines is not scanned again for each one). When the END line
501// is missing (`head key.pem`), the base64 lines that follow the BEGIN line
502// are still taken, and only those.
503const PRIVATE_KEY =
504  /-----BEGIN[^\n-]{0,40}PRIVATE KEY(?: BLOCK)?-----(?:(?:(?!-----BEGIN)[\s\S]){0,65536}?-----END[^\n-]{0,40}PRIVATE KEY(?: BLOCK)?-----|(?:\r?\n(?:[A-Za-z][A-Za-z-]{0,40}: [^\n]*|[ \t]*))*(?:\r?\n[A-Za-z0-9+/=]{4}[A-Za-z0-9+/=]*(?=\r?\n|$))+)/g
505const PUTTY_PRIVATE = /(Private-Lines: \d+\r?\n)((?:[A-Za-z0-9+/=]{1,80}(?:\r?\n|$))+)/g
506
507// A run of dot-separated parts, where a JWT may hide. It has one starting
508// point per run, so a long run is scanned once. A JWT regex of its own went
509// quadratic on base64 JSON, where every row starts with `eyJ`.
510const DOTTED = /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_-]+)+/g
511
512// The password inside a connection string: postgres://user:REDACTED@host.
513// The user may be empty (redis://:pw@host), and the password may hold `/` or
514// `@`: it ends at the `@` that is followed by a host.
515const URL_PASSWORD =
516  /\b([a-z][a-z0-9+.-]{1,20}:\/\/[^\s/@:[\]]{0,80}):(?!\d{1,5}(?:[/?#]|$))([^\s"'`<>]{3,200}?)@(?=[A-Za-z0-9.-]{1,253}(?::\d{1,5})?(?:[/?#\s"'`<>]|$))/gi
517
518// A token used alone as the userinfo: `https://<token>@dev.azure.com/...`,
519// the documented clone form for Azure DevOps, GitHub and Bitbucket tokens.
520const URL_USERINFO = /\b([a-z][a-z0-9+.-]{1,20}:\/\/)([A-Za-z0-9_-]{20,200})@(?=[A-Za-z0-9.-]{1,253}(?::\d{1,5})?[/:])/g
521
522// A credential in a URL query: ?api_key=, &sig=, ?accessToken=. The value may
523// be percent-encoded, so it is decoded before it is judged.
524const URL_KEYWORD =
525  '(?:[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Aa]pikey|[Kk]ey|KEY|[Tt]oken|TOKEN|[Ss]ecret|SECRET|[Ss]ig|SIG|[Ss]ignature|SIGNATURE|[Pp]assword|PASSWORD|[Pp]asswd|[Pp]wd|PWD|[Aa]uth|AUTH|[Cc]redential|CREDENTIAL)'
526const URL_PARAM = new RegExp(
527  String.raw`([?&;](?:[A-Za-z0-9._-]*[_.-]|[a-z0-9]+(?=[A-Z]))?${URL_KEYWORD}=)((?:[A-Za-z0-9+_~-]|%[0-9A-Fa-f]{2}){8,400}={0,2})(?=[&#\s"'<>)\]]|$)`,
528  'g',
529)
530
531// Values after a password-like key, judged by the key and not by entropy:
532// a human password (`Summer2024!`, `hunter2hunter2`) has too little.
533const PW_WORD = String.raw`(?:password|passwd|passphrase|secret|secret[_-]?key|client[_-]?secret|api[_-]?key|apikey|private[_-]?key|secret[_-]?access[_-]?key|access[_-]?key|auth[_-]?token)`
534// Short password names (`pass`, `pw`, `pwd`, `passcode`). They count only as
535// the whole key or after a separator, so `compass` and `bypass` are left.
536const SHORT_PW = String.raw`(?:pass(?:[_-]?code)?|pwd?)`
537const SHORT_KEY = new RegExp(String.raw`(?:^|[_.-])${SHORT_PW}$`, 'i')
538// A short key (`pass:`) with a number or a short word after it is a count
539// or a status, not a password.
540const shortKeyStatus = (key: string, v: string): boolean => SHORT_KEY.test(key) && (/^[0-9]+$/.test(v) || /^[a-z]{1,8}$/.test(v))
541const STRONG_QUOTED = new RegExp(
542  String.raw`((?:^|[\s{,;(\[])(\\?["']?)((?:[A-Za-z0-9._-]*[_.-]|pg)?${PW_WORD}|(?:[A-Za-z0-9._-]*[_.-])?${SHORT_PW})\2[ \t]*[:=][ \t]*)(\\?["'])([^\n]{3,200}?)\4`,
543  'gim',
544)
545// A YAML list item (`- password: x`, compose `- DB_PASS=x`) counts too.
546const STRONG_LINE = new RegExp(
547  String.raw`^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?([A-Za-z0-9._-]*${PW_WORD}|(?:[A-Za-z0-9._-]*[_.-])?${SHORT_PW})[ \t]*[:=][ \t]*)([^\s"'#;][^\s"';]{2,199})[ \t]*$`,
548  'gim',
549)
550// `DB_PASS=`: upper case with a separator only, so prose `Pass:` is left.
551const ENV_PASS = /^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?()[A-Z0-9_]*_PASS[ \t]*[:=][ \t]*)([^\s"'#;][^\s"';]{2,199})[ \t]*$/gm
552// A Gmail app password written unquoted: four groups of four letters.
553const APP_PASSWORD = new RegExp(
554  String.raw`^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?[A-Za-z0-9._-]*${PW_WORD}[ \t]*[:=][ \t]*)([a-z]{4}(?: [a-z]{4}){3})[ \t]*$`,
555  'gim',
556)
557// The line after a YAML block indicator: `password: |` then the value.
558const BLOCK_PASSWORD = new RegExp(
559  String.raw`^([ \t]*(?:-[ \t]+)?["']?[A-Za-z0-9._-]*${PW_WORD}["']?[ \t]*:[ \t]*[|>][-+]?[0-9]?[ \t]*\r?\n[ \t]+)(\S[^\n]{2,199}?)[ \t]*(?=\r?$)`,
560  'gim',
561)
562// SQL: `CREATE USER x WITH PASSWORD '...'`, MySQL `IDENTIFIED BY '...'`.
563const SQL_PASSWORD =
564  /(\b(?:(?:with|encrypted|unencrypted)[ \t]+password|identified[ \t]+(?:with[ \t]+\S+[ \t]+)?by(?:[ \t]+password)?)[ \t]+)'([^'\n]{3,200})'/gi
565// Terraform: the default of a variable whose name says it is a password.
566const HCL_DEFAULT = new RegExp(String.raw`(\bvariable[ \t]+"[\w-]*${PW_WORD}"[ \t]*\{[^{}]{0,400}?\bdefault[ \t]*=[ \t]*")([^"\n]{3,200})"`, 'gi')
567// Terraform state: the result of a random_password (usually a database
568// master password), and an output that sits under a password-like name.
569const TF_RESULT = /("result"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"/g
570const TF_OUTPUT = new RegExp(
571  String.raw`("([\w.-]{1,100})"\s*:\s*\{\s*(?:"(?:sensitive|type)"\s*:\s*[^,{}]{1,40},\s*){0,4}"value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"`,
572  'g',
573)
574// A name and its value as sibling fields: a HAR header or form field, a
575// Kubernetes env entry. The name decides; the value sits apart from it.
576const JSON_NAME_VALUE = /("name"\s*:\s*"([^"\n]{1,100})"\s*,\s*"value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"/g
577const JSON_VALUE_NAME = /("value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})("\s*,\s*"name"\s*:\s*"([^"\n]{1,100})")/g
578const YAML_NAME_VALUE =
579  /^([ \t]*)(-[ \t]+name:[ \t]*["']?([\w.-]{1,100})["']?[ \t]*\r?\n\1[ \t]+value:[ \t]*(["']?))([^\n"']{3,200}?)\4[ \t]*(?=\r?$)/gm
580const PW_NAME = new RegExp(String.raw`^[\w.-]*${PW_WORD}$|^(?:[\w.-]*[_.-])?${SHORT_PW}$`, 'i')
581// SQL: an INSERT whose column list names a key or a password.
582const SQL_INSERT = /\bINSERT[ \t]+INTO[ \t]+[\w."`[\]]{1,100}[ \t]*\(([^()\n]{1,1000})\)\s*VALUES\s*/gi
583// `PGPASSWORD=x psql` is the one common env name with no separator.
584const KV_PASSWORD = /((?:^|[\s;&?,])-{0,2}(?:[A-Za-z0-9._-]*[_.-]|pg)?(?:password|passwd)=|[;&?]pwd=)([^\s"'&;,]{3,200})/gim
585
586// A generated password in quotes, with no name beside it: upper, lower,
587// digit and a symbol from the Terraform and password-manager sets, no space,
588// no path or regex characters. The token scan cuts such a value at each
589// symbol, so it is judged whole here.
590// The opening quote must follow a space, `=`, `:`, `(`, `,`, `[` or `{`, so
591// the text between two strings (`"0"=20cellpadding=3D"`) is not taken.
592const QUOTED_VALUE = /(?<=^|[\s:=(,[{])(["'])([^\s"'\\/.,;`]{12,128})\1/gm
593const PW_SYMBOL = /[!#$%&*()=+[\]{}<>:?@^~]/
594
595function looksGenerated(v: string): boolean {
596  if (!/[A-Z]/.test(v) || !/[a-z]/.test(v) || !/[0-9]/.test(v) || !PW_SYMBOL.test(v)) return false
597  if (/\$\{|\{\{|%\(|^\^|\$$|[A-Za-z]-[A-Za-z]\]|=>|::|\(\)|<\/?[a-z]|=[0-9A-F]{2}|[0-9]{6}/.test(v)) return false
598  // Real words in it: code, a format, a shortcut (`Ctrl+Alt+F12`), a payee
599  // with an order number (`Merchant#a12345`).
600  const words = v.match(/[A-Za-z]{4,}/g)?.filter((w) => WORDY.test(w)) ?? []
601  if (words.length >= 2 || words.some((w) => w.length >= 6)) return false
602  // Mostly letters and digits: a run of symbols is binary or markup.
603  if ((v.match(/[A-Za-z0-9]/g)?.length ?? 0) * 5 < v.length * 3) return false
604  return entropy(v) >= 3.5
605}
606
607// Things that stand in for a value without being one: a variable, a call, a
608// template, a placeholder, a type, the key's own name, a path.
609// A call has to close (`getSecret("x")`), and a YAML tag is a word
610// (`!Ref DbPassword`), so a generated password that starts `ab(` or `!` is
611// not taken for one.
612const REFERENCE =
613  /^[A-Za-z_$][\w$.]*\(.*\)[,;]?$|^\$|^\{\{|^<.*>$|^%\(|^!!?[A-Za-z][\w:]*(?:[ \t]|$)|^\*+$|^(?:true|false|null|none|nil|undefined|required|optional|string|str|bool|int|number)$|^(?:process\.env|os\.environ|env\.|config\.|settings\.)|^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/i
614const SELF_REF =
615  /^[{`(]?(?:[a-z][A-Za-z0-9]*)?(?:Password|Passwd|Secret|Key|Token|Pwd)[A-Za-z0-9]*[)},`]*$|^(?:password|passwd|secret|apikey|api_key|pwd|token)[),}]*,?$/
616const PLACEHOLDER = /^(?:x{3,}|\.{3,}|…|(?:your|my|<)[_ -]?[a-z_ -]*(?:key|password|secret|token)[a-z_ -]*>?)$/i
617
618function isStandIn(v: string): boolean {
619  return (
620    REFERENCE.test(v) ||
621    SELF_REF.test(v) ||
622    PLACEHOLDER.test(v) ||
623    /^(?:[/~]|\.\.?\/)/.test(v) ||
624    /^\{[\w$.]+\}$/.test(v) ||
625    // A test fixture made of words: `test-internal-key`, `fake_secret`.
626    (/^(?:[a-z]+[_-])*(?:test|fake|dummy|mock|sample|example|placeholder|changeme)(?:[_-][a-z]+)*$/.test(v) && /[_-]/.test(v)) ||
627    // `password: pw,` in an object literal is code, not a value.
628    /^[A-Za-z_$][\w$]*,$/.test(v)
629  )
630}
631
632// Credentials on a command line: `--password x`, `curl -u user:pw`,
633// `mysql -pPW`, a .pgpass line, a .netrc entry, `gh secret set --body`.
634const CLI_FLAG =
635  /((?:^|\s)--?(?:password|passwd|pass|api-key|apikey|api_key|token|client-secret|auth-token|access-token)(?:=|[ \t]+)(["']?))(?![$<{%])([^\s"'=-][^\s"']{2,200})\2/g
636const CLI_USERPASS =
637  /(\b(?:curl|wget|http|https|xh|httpie|lftp|ftp)\b[^\n|;&]*?\s(?:-u|-U|--user|--proxy-user)(?:[ \t]+|=)["']?[^\s:"'$]+:)(?!\$)([^\s"'`]{3,200})/g
638const MYSQL_P =
639  /(\b(?:mysql(?:dump|admin|import|sh|check|slap|binlog)?|mariadb(?:-dump|-admin)?)\b[^\n|;&]*?\s-p)(?!\$)([^\s`-][^\s`]{2,200})/g
640const PGPASS =
641  /^((?:\*|[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\]):(?:\d{1,5}|\*):(?![0-9]+:)[A-Za-z0-9_$*.-]+:[A-Za-z0-9_$*.@-]+:)(?!\/\/)(\S{3,200})$/gm
642const NETRC =
643  /((?:^|\n)[ \t]*(?:machine[ \t]+\S+|default)(?:\s+(?:login|account|port)[ \t]+\S+)*\s+password[ \t]+)(\S{3,200})(?=[ \t]*(?:\r?\n|$)|\s+(?:machine|default|login|account|macdef)\b)/g
644const GH_SECRET = /(\bgh[ \t]+secret[ \t]+set\b[^\n;&|]*?[ \t](?:-b|--body)(?:[ \t]+|=)(["']?))([^\s"']{3,400})(?=\2)/g
645// Short password flags that only mean a password for one command:
646// `sshpass -p`, `docker login -p`, `mongosh -p`, `7z -pPW`, `sqlcmd -P`,
647// `zip -P`, `redis-cli -a`, `ldapsearch -w`. Each is anchored at its command.
648const CMD_PW_TOOLS: [cmd: string, flag: string][] = [
649  [String.raw`sshpass|(?:docker|podman|nerdctl|helm[ \t]+registry)[ \t]+login|mongo(?:sh|dump|restore|import|export)?`, String.raw`-p[ \t]*`],
650  [String.raw`7za?|7zz`, '-p'],
651  [String.raw`sqlcmd|zip`, String.raw`-P[ \t]+`],
652  [String.raw`redis-cli|valkey-cli`, String.raw`-a[ \t]+`],
653  [String.raw`ldap[a-z]+`, String.raw`-w[ \t]+`],
654]
655const CMD_PW = CMD_PW_TOOLS.map(
656  ([cmd, flag]) => new RegExp(String.raw`(\b(?:${cmd})\b[^\n|;&]*?[ \t]${flag}(["']?))(?![$-])([^\s"']{3,200})\2`, 'g'),
657)
658const CMD_PW_GATE = /sshpass|login|mongo|7z|sqlcmd|zip|redis-cli|valkey-cli|ldap/
659// `openssl ... -passout pass:PW`, `smbclient -U user%PW`, `htpasswd -b file
660// user PW`, `rabbitmqctl add_user user PW`.
661const OPENSSL_PASS = /(-pass(?:in|out)?[ \t]+pass:)([^\s"']{3,200})/g
662const SMB_USER = /(\bsmbclient\b[^\n|;&]*?[ \t]-U[ \t]+["']?[^\s%"']+%)([^\s"']{3,200})/g
663const HTPASSWD = /(\bhtpasswd[ \t]+-[a-zA-Z]*b[a-zA-Z]*[ \t]+(?:-[a-zA-Z]+[ \t]+)*\S+[ \t]+\S+[ \t]+)(["']?)([^\s"']{3,200})\2/g
664const NET_USER = /(\bnet[ \t]+user[ \t]+(?:"[^"\n]{1,64}"|\S+)[ \t]+)(["']?)((?!\/)[^\s"'*]{3,200})\2/gi
665const RABBIT_USER = /(\brabbitmqctl[ \t]+(?:add_user|change_password)[ \t]+\S+[ \t]+)(["']?)([^\s"']{3,200})\2/g
666
667// Cookies: every value in a Cookie header, curl -b, or cookies.txt.
668const COOKIE = /((?:^|["'\s])(?:(?:set-)?cookie["']?[ \t]*:|-b|--cookie)[ \t]*["']?)([^"'\n]*)/gim
669const COOKIE_PAIR = /(^|;[ \t]*)([^=;\s]+=)([^;\s]{16}[^;\s]*)/g
670const COOKIE_ATTR = /^(?:path|domain|expires|max-age|samesite|priority)=$/i
671const COOKIE_VALUE = /^(?:s%3A)?([A-Za-z0-9_+%-]{16}[A-Za-z0-9_+%-]*)(?:\.[A-Za-z0-9_+%/-]{16}[A-Za-z0-9_+%/-]*)?={0,2}$/i
672const COOKIES_TXT =
673  /^((?:#HttpOnly_)?\.?[\w.-]+\t(?:TRUE|FALSE)\t\S+\t(?:TRUE|FALSE)\t\d+\t[^\t\n]+\t)([^\t\r\n]{16}[^\t\r\n]*)$/gm
674const HAR_COOKIE = /("name"\s*:\s*"[^"]*(?:sess|sid|token|auth)[^"]*"\s*,\s*"value"\s*:\s*")([^"]{16}[^"]*)"/gi
675
676// Basic auth and Docker `auth`: base64 of `user:password`, often short.
677const BASIC_AUTH = /((?:["']auth["'][ \t]*:[ \t]*["']|\b[Bb]asic[ \t]+))([A-Za-z0-9+/]{8,200}={0,2})(?![A-Za-z0-9+/=])/g
678// A Kubernetes bootstrap token: `abcdef.0123456789abcdef`.
679const BOOTSTRAP = /\b([a-z0-9]{6})\.([a-z0-9]{16})\b/g
680
681// Inline media in a data: URI. Only media types: a key put in a JSON or text
682// data URI is still scanned. The length floor stops a short key placed after
683// a fake image prefix from being excused.
684const DATA_URI =
685  /data:(?:(?:image|font|audio|video)\/[\w.+-]+|application\/(?:x-)?font-[\w.+-]+|application\/vnd\.ms-fontobject)(?:;[\w-]+=[\w.-]+)*;base64,[A-Za-z0-9+/]{64}[A-Za-z0-9+/]*={0,2}/g
686
687// A base64 blob: one long run, or three or more wrapped lines (a PEM bundle,
688// a MIME part). A trailing short line is only taken when a blank line, a
689// `--boundary` or the end of the text follows it.
690const B64_LINE = String.raw`[A-Za-z0-9+/]{40}[A-Za-z0-9+/]*={0,2}\r?\n`
691const BLOB = new RegExp(
692  String.raw`(?<![A-Za-z0-9+/=])(?:(?:${B64_LINE}){3}(?:${B64_LINE})*(?:[A-Za-z0-9+/]+={0,2}(?=\r?\n[\r\n-]|\r?\n?$))?|[A-Za-z0-9+/]{256}[A-Za-z0-9+/]*={0,2})`,
693  'g',
694)
695const BLOB_NAME =
696  /(?:key|token|secret|password|passwd|credential|auth|private|signature|session|cookie|dsn|cert[_-]?key)[\w.-]*["'\]\s]{0,4}[:=]{1,2}\s*["'`]?\s*$/i
697const BLOB_INNER = /PRIVATE KEY|OPENSSH|"private_key"|"client_secret"|"refresh_token"|"password"|"auths"/
698
699// A Claude thinking-block signature, and a redacted thinking block: opaque
700// to everyone and useless as a credential. Only the value is parked; the
701// thinking text beside it is still scanned.
702const THINKING_SIG =
703  /("type"\s*:\s*"thinking"\s*,\s*"thinking"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*,\s*"signature"\s*:\s*")([A-Za-z0-9+/]{200}[A-Za-z0-9+/]*={0,2})(?=")/g
704const REDACTED_THINKING = /("type"\s*:\s*"redacted_thinking"\s*,\s*"data"\s*:\s*")([A-Za-z0-9+/]{100}[A-Za-z0-9+/]*={0,2})(?=")/g
705
706// A lockfile or SRI integrity hash: exact digest lengths only, so a key
707// glued after `sha256-` is still judged.
708const SRI = /\bsha(?:1-[A-Za-z0-9+/]{27}=|256-[A-Za-z0-9+/]{43}=|384-[A-Za-z0-9+/]{64}|512-[A-Za-z0-9+/]{86}==)(?![A-Za-z0-9+/=])/g
709
710// Links whose path is kept as written: public font files (the file name is
711// a hash, not a secret) and the hosts of the `allowUrlHosts` option (a task
712// link like `/app/task/<slug>-<id>`). The host has to follow the scheme and be
713// followed by a port or `/`, so `evil.com/<host>/`, `<host>.evil.com` and
714// `<host>@evil.com` don't match. The slashes may be JSON-escaped (`\/`). Path
715// characters only, so a query string or fragment is still scanned.
716type AllowUrl = { hint: RegExp; re: RegExp }
717const BUILT_IN_URL_HOSTS = ['fonts.gstatic.com']
718const HOST_NAME = /^(?:\*\.)?[a-z0-9_](?:[a-z0-9_.-]*[a-z0-9])?$/
719const IPV6_HOST = /^\[[0-9a-f:.]+\]$/
720
721// `https://Host:8080/path` becomes `host`. An IPv6 literal keeps its brackets.
722function cleanHost(entry: string): string {
723  const h = entry.toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[/?#].*$/, '').replace(/^[^@]*@/, '')
724  return h.startsWith('[') ? h.replace(/\](?::\d+)?$/, ']') : h.replace(/:\d*$/, '')
725}
726
727function allowUrlHosts(entries: string[]): { hosts: string[]; rejected: string[] } {
728  const hosts: string[] = []
729  const rejected: string[] = []
730  for (const e of entries) {
731    const h = cleanHost(e)
732    if (HOST_NAME.test(h) || IPV6_HOST.test(h)) hosts.push(h)
733    else rejected.push(e)
734  }
735  return { hosts, rejected }
736}
737
738const reEscape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
739
740// `*.example.com` stands for any subdomain of example.com, not example.com.
741function allowUrlPattern(hosts: string[]): AllowUrl {
742  const all = [...new Set([...BUILT_IN_URL_HOSTS, ...hosts])]
743  const alt = all.map((h) => (h.startsWith('*.') ? String.raw`(?:[a-z0-9_-]+\.)+${reEscape(h.slice(2))}` : reEscape(h))).join('|')
744  const hint = new RegExp(all.map((h) => reEscape(h.replace(/^\*\./, ''))).join('|'), 'i')
745  const slash = String.raw`(?:\/|\\\/)`
746  return { hint, re: new RegExp(String.raw`https?:${slash}${slash}(?:${alt})(?::\d{1,5})?${slash}(?:[A-Za-z0-9_.~%-]|${slash})*`, 'gi') }
747}
748
749// Parks the path of each allowed link before anything else runs, so neither
750// the token scan nor a value hidden elsewhere in the text can touch it. A path
751// holding a vendor-shaped key is not parked; the passes judge it as usual.
752// Parking also ends the path cleanly: in `/x/ab+<key>` only `/x/ab` is
753// parked, and `<key>` is judged on its own.
754function parkAllowedUrls(text: string, cfg: Config): string {
755  if (!text.includes(':') || !cfg.allowUrl.hint.test(text)) return text
756  return text.replace(cfg.allowUrl.re, (m) => (scrubVendors(m, cfg) === m ? park(m) : m))
757}
758
759// Mail: an optional `cat -n`, Read or `grep -n` prefix on each line.
760const PFX = String.raw`(?:[ \t]*\d+\t|[^\s:]+:\d+[:-])?`
761const PFX_RE = new RegExp(`^${PFX}`)
762const CTE = new RegExp(String.raw`^${PFX}Content-Transfer-Encoding:[ \t]*base64[ \t]*\r?$`, 'gim')
763const CONTENT_TYPE = new RegExp(String.raw`^${PFX}Content-Type:[ \t]*(?:\r?\n${PFX}[ \t]+)?([^;\s]+)`, 'im')
764const MAIL_HEADER_NAMES =
765  'DKIM-Signature|X-Google-DKIM-Signature|ARC-Seal|ARC-Message-Signature|ARC-Authentication-Results|Authentication-Results|Message-I[Dd]|References|In-Reply-To|Feedback-ID|X-SG-EID|X-SG-ID|X-MSFBL|X-Gm-Message-State|X-Google-Smtp-Source|X-Gm-Gg|X-Gm-Features|X-Received|Received-SPF|Return-Path|Received'
766const MAIL_HEADER = new RegExp(String.raw`^${PFX}(?:${MAIL_HEADER_NAMES}):[^\n]*(?:\r?\n${PFX}[ \t][^\n]*){0,12}`, 'gm')
767const HEADER_LINE = new RegExp(String.raw`^${PFX}[A-Z][A-Za-z0-9-]*:`)
768const ENCODED_WORD = /=\?[\w-]+\?[Bb]\?[A-Za-z0-9+/=]{4}[A-Za-z0-9+/=]*\?=/g
769const BOUNDARY = /Content-Type:[ \t]*multipart\/[^\n]*?(?:\r?\n[ \t][^\n]*?)?\bboundary="?([^"\s;]+)"?/gi
770
771// The generic scan. A slash-bearing base64 run (an AWS secret, `openssl rand
772// -base64`) is judged whole first; anything else is cut into candidate
773// tokens. `/` and `.` are left out of a candidate on purpose: with them in,
774// every long file path becomes one. A token never starts inside a `%XX`
775// escape, and `=` only counts as padding when nothing follows it.
776const CAND_BODY = String.raw`[A-Za-z0-9+_-]{12,200}(?:={1,2}(?![A-Za-z0-9=]))?`
777const CANDIDATE_SRC = String.raw`(?<=%[0-9A-Fa-f]{2})(?<!%25)${CAND_BODY}|(?<=%25[0-9A-Fa-f]{2})${CAND_BODY}|(?<!%[0-9A-Fa-f]?|%25[0-9A-Fa-f]?)${CAND_BODY}`
778const SLASHED_SRC = String.raw`(?<![A-Za-z0-9+/_.%~-])[A-Za-z0-9+/]{20,300}={0,2}(?![A-Za-z0-9+/=_.%~-])`
779const CANDIDATE = new RegExp(CANDIDATE_SRC, 'g')
780const SCAN = new RegExp(`(${SLASHED_SRC})|${CANDIDATE_SRC}`, 'g')
781
782// A name to the left of the candidate that says the candidate is a key:
783// `API_KEY=`, `"token":`, an escaped `\"token\":`, `&quot;token&quot;:`,
784// `Authorization: Bearer `, `Authorization: <any scheme> `, `--api-key `, a
785// table cell, a YAML block, a Go header map `X-Api-Key:[`, a Python header
786// tuple `('X-Api-Key', '`. `PAT` is a personal access token, not `PATH`.
787const NAME_WORDS =
788  'key|token|secret|password|passwd|pwd|credential|auth|bearer|private|signature|session|cookie|dsn|salt|nonce|otp|sess(?:ion)?[_-]?id|(?<![A-Za-z])sid|(?<![A-Za-z])sig|(?<![A-Za-z])pat(?![a-z])'
789const Q = String.raw`(?:\\{0,3}["'${'`'}]|&(?:quot|apos|#0*3[49]|#x0*2[27]);|\\u00(?:22|27))`
790const NAMED = new RegExp(
791  '(?:' +
792    String.raw`(?:${NAME_WORDS})(?:(?<=key)[_-]?id)?(?:${Q}|\]|\s){0,6}[:=]{1,2}(?:${Q}|\s){0,4}\[?` +
793    String.raw`|(?:${NAME_WORDS})[\w-]{0,20}${Q}[ \t]*,[ \t]*${Q}` +
794    String.raw`|\bbearer[ \t]+["']?` +
795    String.raw`|authorization["']?[ \t]*[:=][ \t]*["']?(?:[A-Za-z][\w-]{0,31}[ \t]+)?` +
796    String.raw`|(?:^|\s)--?(?![a-z_-]*(?:public|pub|cache|idempotency|signing|recv)[_-]?key)[a-z_-]*(?:key|token|secret|password|passwd|pw|auth)[ \t]+["']?` +
797    String.raw`|\|[ \t]*[\w.-]*(?:key|token|secret|password)[ \t]*\|[ \t]*` +
798    String.raw`|(?:key|token|secret|password|passwd|pwd|credential|auth)[\w-]*["']?[ \t]*:[ \t]*[>|][-+1-9]{0,2}[ \t]*\r?\n[ \t]+` +
799    ')$',
800  'i',
801)
802
803// A sibling field name that makes its value a key: the value is then
804// judged as if the name stood right before it.
805const KEY_NAME = new RegExp(String.raw`(?:${NAME_WORDS}|authorization)`, 'i')
806
807const HEX_ONLY = /^[0-9a-f]+$/i
808const PREFIXED_KEY = /^[a-z]{2,12}(?:_v[0-9])?[_-]([a-z0-9]{32,})$/
809const DIGITS_ONLY = /^[0-9]+$/
810const LETTERS_ONLY = /^[A-Za-z]+$/
811
812// Public ids with a shape rather than a prefix: a YouTube channel or
813// playlist, a Monzo transaction, a Vercel deployment, team or project, an
814// OpenAI org, a Claude Code session, environment or server tool call. Exact
815// lengths only, and the Anthropic ids start `01`, so any other token after
816// the same prefix is still judged.
817const PUBLIC_SHAPE =
818  /^(?:UC[A-Za-z0-9_-]{22}|PL[A-Za-z0-9_-]{16,32}|(?:tx|mm|acc|pot|merch|user)_0000[A-Za-z0-9]{18}|dpl_[A-Za-z0-9]{24,32}|org-[A-Za-z0-9]{24}|team_[A-Za-z0-9]{24}|prj_[A-Za-z0-9]{28}|(?:session|env|srvtoolu)_01[A-Za-z0-9]{22})$/
819
820// Prefixes that name a PUBLIC object id, not a key. Stripe hands these out in
821// dashboards, invoices and source code; hiding them makes a session useless
822// and protects nothing. `pk_live_` is Stripe's publishable key, also public.
823const PUBLIC_PREFIX =
824  /^(?:price|prod|cus|sub|sched|in|ch|pi|cs|py|re|txn|il|si|seti|evt|acct|promo|coupon|plan|card|ba|src|dp|du|iv|ii|rcpt|file|link|pm|tok|pk|test|toolu|msg|req|run|wf)_/i
825// A public id is the prefix, maybe `live_` or `test_`, then one alnum body.
826// `prod_api_key_<random>` is not that shape, so it is judged like any token.
827const PUBLIC_ID_BODY = /^(?:(?:live|test)_)?[A-Za-z0-9]+$/
828// A WooCommerce consumer secret reuses Stripe's `cs_`; Stripe's never is
829// 40 lowercase hex.
830// A Klaviyo private key reuses `pk_`, with no `live_` or `test_` after it.
831const NOT_PUBLIC = /^cs_[a-f0-9]{40}$|^pk_(?!live_|test_)[A-Za-z0-9]{34}$/
832// Generic prefixes a key name in front may override (`API_KEY=test_...`).
833const SOFT_PUBLIC = /^(?:test|tok)_/i
834
835// A name written in code (`archived-modules-marker`, `handleCheckoutSession`,
836// `lesson_article_outline_open_v1`) reads as high entropy but is a word list.
837// A key is not built out of words.
838const WORD_PART = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3}|[A-Z]{2,})$/
839const ALNUM_ONLY = /^[A-Za-z0-9]+$/
840const SEGMENTS = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g
841const WORDY = /^[A-Za-z]?[a-z]{2,}$/
842const SLASH_WORD = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3})$/
843const STRICT_CAMEL = /^(?:[a-z]{2,}|[A-Z][a-z]{2,})(?:[A-Z][a-z]{2,})+[0-9]{0,3}$/
844
845// `playerEventBatchV1Schema` splits into six segments of which four are
846// words; a random key splits into many segments of which almost none are.
847// A camel name has a capital in it: an all-lowercase token with a digit run
848// in the middle is a random value more often than a name.
849function isCamelName(token: string): boolean {
850  if (!ALNUM_ONLY.test(token) || !/[A-Z]/.test(token)) return false
851  const segs = token.match(SEGMENTS)
852  if (!segs || segs.join('') !== token) return false
853  const words = segs.filter((seg) => WORDY.test(seg)).length
854  return words >= 2 && words >= segs.length - 2
855}
856
857// Every character used once AND mostly in alphabet order: a charset constant.
858// The order test matters: a random 24-character key has all distinct
859// characters about 1% of the time.
860function isAlphabet(token: string): boolean {
861  if (token.length < 20) return false
862  let runs = 0
863  for (let i = 1; i < token.length; i++) if (token.charCodeAt(i) === token.charCodeAt(i - 1) + 1) runs++
864  return runs * 2 >= token.length && new Set(token).size === token.length
865}
866
867// Half or more of the characters follow the one before in code order:
868// `0123456789abcdef`, the base64 alphabet. A sample in docs, never a key.
869function countsUp(s: string): boolean {
870  let runs = 0
871  for (let i = 1; i < s.length; i++) if (s.charCodeAt(i) === s.charCodeAt(i - 1) + 1) runs++
872  return runs * 2 >= s.length
873}
874
875function isMixed(s: string): boolean {
876  return /[a-z]/.test(s) && /[A-Z]/.test(s) && /[0-9]/.test(s)
877}
878
879// One word, or a number of up to four digits (a year, a month, a day).
880function isSlugPart(part: string): boolean {
881  return WORD_PART.test(part) || /^[0-9]{1,4}$/.test(part)
882}
883
884// A part made only of words, short acronyms and short numbers: `expenseID`,
885// `2026`, `v2Final`. It names something; it is not a random remainder.
886function isWordish(part: string): boolean {
887  const segs = part.match(SEGMENTS)
888  if (!segs || segs.join('') !== part) return false
889  return segs.every((seg) => WORDY.test(seg) || /^[A-Z]{2,4}$/.test(seg) || /^[0-9]{1,4}$/.test(seg))
890}
891
892function looksLikeName(token: string, named: boolean): boolean {
893  if (isCamelName(token)) return true
894  // A name beside the token outweighs the alphabet rule.
895  if (!named && isAlphabet(token)) return true
896  const parts = token.split(/[_-]/)
897  if (parts.length < 2) return false
898  // Beside a key's name, one key-like part makes the whole token a key:
899  // `django-insecure-<random>`, `live-secret-<random>`.
900  if (named && parts.some((p) => p.length >= 16 && /[0-9]/.test(p) && /[A-Za-z]/.test(p) && entropy(p) >= 3.0)) return false
901  // A long random remainder outweighs any words in front of it
902  // (`vercel_blob_rw_<random>`, `dckr_pat_<random>`). Mixed case is asked
903  // for, so a hex cache key or a timestamp is not a remainder.
904  const residue = parts.filter((p) => !WORD_PART.test(p) && !isCamelName(p) && !isWordish(p)).join('')
905  // Words plus one long number (a LinkedIn post slug, a Zoom recording, an
906  // Amazon ref tag): the number is an id, not part of a random remainder.
907  // Only the parts that are neither words, numbers nor date stamps count,
908  // and camel parts are not excused here, since a random string can pass
909  // the camel test.
910  const longNumber = !named && parts.some((p) => /^[A-Z]{0,4}[0-9]{6,}$/.test(p))
911  const randomPart = longNumber ? parts.filter((p) => !WORD_PART.test(p) && !/^[0-9]{1,4}$/.test(p) && !DIGITS_ONLY.test(p) && !DATE_STAMP.test(p)).join('') : ''
912  if ((longNumber ? (randomPart.length >= 8 ? 16 : 0) : residue.length) >= 16 && isMixed(residue)) return false
913  // Words, dates and short numbers only (`2025-08-26-lesson`): a slug.
914  if (parts.every(isSlugPart)) return true
915  // Three or more separated parts, two of them plain words: a naming
916  // convention (`META_Conv_Lookalike-Customers_FreeTrial_2024Q1`), not a key.
917  if (parts.length >= 3 && parts.filter((p) => /^[A-Za-z]{4,}$/.test(p)).length >= 2) return true
918  return longNumber && randomPart.length < 8
919}
920const DATE_STAMP = /^[A-Z]{0,4}(?:19|20)[0-9]{6}$/
921
922function isPublicId(token: string, named: boolean, cfg: Config): boolean {
923  if (PUBLIC_SHAPE.test(token)) return true
924  if (cfg.allowPrefix.some((p) => token.startsWith(p))) return true
925  if (!PUBLIC_PREFIX.test(token) || NOT_PUBLIC.test(token)) return false
926  if (cfg.denyPrefix.some((p) => token.startsWith(p))) return false
927  if (named && SOFT_PUBLIC.test(token)) return false
928  return PUBLIC_ID_BODY.test(token.replace(PUBLIC_PREFIX, ''))
929}
930
931// A letters-only key (no digit at all) splits into many short camel
932// segments; a camel name of the same length splits into a few long ones.
933function lettersLookRandom(token: string, named: boolean, h: number): boolean {
934  if (!LETTERS_ONLY.test(token) || !/[a-z]/.test(token) || !/[A-Z]/.test(token)) return false
935  const segs = token.match(SEGMENTS) ?? []
936  if (named && token.length >= 20 && h >= 3.6 && segs.length * 4 >= token.length) return true
937  return token.length >= 24 && h >= 4.0 && segs.length * 3 >= token.length
938}
939
940// Claude Code transcripts and most analytics put a `sessionId` UUID on every
941// line. A UUID beside that name is an identifier, not a credential; a real
942// session cookie is random text, and the cookie rules hide it on their own.
943const SESSION_UUID = new RegExp(`^${UUID}$`, 'i')
944const SESSION_ID_NAME = new RegExp(String.raw`sess(?:ion)?[_-]?id(?:${Q}|\s){0,6}[:=]{1,2}(?:${Q}|\s){0,4}$`, 'i')
945
946function looksSecret(token: string, before: string, cfg: Config): boolean {
947  if (token.startsWith('REDACTED-')) return false
948  if (cfg.allow.has(token) || DIGITS_ONLY.test(token)) return false
949  const named = NAMED.test(before)
950  if (isPublicId(token, named, cfg)) return false
951  // `invoice+statements+acct_<id>`: a mail sender address made of words and
952  // a public id. Each `+` part has to be one, so base64 is still judged.
953  if (token.includes('+') && token.split('+').every((p) => p === '' || WORD_PART.test(p) || isPublicId(p, named, cfg))) return false
954  if (named && SESSION_UUID.test(token) && SESSION_ID_NAME.test(before)) return false
955  if (looksLikeName(token, named)) return false
956
957  const h = entropy(token)
958
959  // A bare hex run is a git SHA or a checksum far more often than it is a
960  // key, so hex needs a name beside it before it is hidden.
961  if (HEX_ONLY.test(token)) return named && token.length >= 24 && h >= cfg.contextMinEntropy
962
963  // A short lowercase prefix and a long random body (imp_ and 40 hex): a
964  // vendor key no rule names. A git SHA has no prefix, so it is not caught here.
965  const prefixed = token.match(PREFIXED_KEY)
966  if (prefixed && /[0-9]/.test(prefixed[1]!) && /[a-z]/.test(prefixed[1]!) && entropy(prefixed[1]!) >= cfg.contextMinEntropy) return true
967
968  if (isMixed(token) && token.length >= cfg.minLength && h >= cfg.minEntropy) return true
969  if (lettersLookRandom(token, named, h)) return true
970  // Next to a key's name the bar is lower, but the token still has to look
971  // like a key: letters and digits together, not a word.
972  const alnum = /[0-9]/.test(token) && /[A-Za-z]/.test(token)
973  return named && alnum && token.length >= 16 && h >= cfg.contextMinEntropy
974}
975
976// A slash-bearing base64 run, judged on the parts that are not words, so a
977// path like `uploads/2026/10/12/Report2Final` stays a path.
978function slashedSecret(run: string, named: boolean, cfg: Config): boolean {
979  if (cfg.allow.has(run)) return false
980  const body = run.replace(/=+$/, '').replace(/^\/+/, '')
981  if (!body.includes('/') || !isMixed(body) || countsUp(body)) return false
982  const parts = body.split('/').filter(Boolean)
983  const isWord = (p: string): boolean => SLASH_WORD.test(p) || STRICT_CAMEL.test(p) || DIGITS_ONLY.test(p)
984  const words = parts.filter(isWord).length
985  if (words * 2 >= parts.length) return false
986  if (!isMixed(parts.filter((p) => !isWord(p)).join(''))) return false
987  const h = entropy(body)
988  return (body.length >= 32 && h >= 4.3) || (named && body.length >= 16 && h >= 3.6)
989}
990
991// -------------------------------------------------------------- the passes
992
993// Mints `value` unless it is already a placeholder, allowed, or a stand-in.
994function pairOut(cfg: Config, head: string, value: string, tail = ''): string {
995  if (WHOLE_TAG.test(value) || cfg.allow.has(value) || isStandIn(value)) return head + value + tail
996  return head + mint('SECRET', value) + tail
997}
998
999function parkOpaque(text: string): string {
1000  let out = text
1001  if (out.includes('"signature"')) out = out.replace(THINKING_SIG, (_m, head: string, sig: string) => head + park(sig))
1002  if (out.includes('redacted_thinking')) out = out.replace(REDACTED_THINKING, (_m, head: string, data: string) => head + park(data))
1003  if (out.includes('sha')) out = out.replace(SRI, (m) => park(m))
1004  return out
1005}
1006
1007function scrubKeyBlocks(text: string, cfg: Config): string {
1008  let out = text
1009  if (out.includes('PRIVATE KEY')) out = out.replace(PRIVATE_KEY, (m) => (cfg.allow.has(m) ? m : mint('SECRET', m)))
1010  if (out.includes('Private-Lines:')) out = out.replace(PUTTY_PRIVATE, (_m, head: string, body: string) => head + mint('SECRET', body))
1011  return out
1012}
1013
1014// Walks the dot-separated parts of a run: a part holding `eyJ` and the two
1015// parts after it are a JWT, when each is long enough.
1016function scrubJwtChain(chain: string, cfg: Config): string {
1017  if (!chain.includes('eyJ')) return chain
1018  const segs = chain.split('.')
1019  const out: string[] = []
1020  let k = 0
1021  while (k < segs.length) {
1022    const i = k + 2 < segs.length ? segs[k]!.indexOf('eyJ') : -1
1023    if (i >= 0 && segs[k]!.length - i >= 11 && segs[k + 1]!.length >= 8 && segs[k + 2]!.length >= 8) {
1024      const jwt = `${segs[k]!.slice(i)}.${segs[k + 1]}.${segs[k + 2]}`
1025      out.push(segs[k]!.slice(0, i) + (cfg.allow.has(jwt) ? jwt : mint('SECRET', jwt)))
1026      k += 3
1027    } else {
1028      out.push(segs[k]!)
1029      k += 1
1030    }
1031  }
1032  return out.join('.')
1033}
1034
1035// `postgres://user:REDACTED@host` in docs: the word itself stands in for one.
1036const URL_PASSWORD_WORD = /^(?:password|passwd|pass|pw|pwd|secret|<[^>]*>)$/i
1037
1038function scrubUrls(text: string, cfg: Config): string {
1039  let out = text
1040  if (out.includes('://')) {
1041    out = out.replace(URL_PASSWORD, (m, head: string, password: string) =>
1042      WHOLE_TAG.test(password) || cfg.allow.has(password) || URL_PASSWORD_WORD.test(password) ? m : `${head}:${mint('SECRET', password)}@`,
1043    )
1044    out = out.replace(URL_USERINFO, (m, head: string, user: string) => {
1045      if (cfg.allow.has(user) || !/[0-9]/.test(user) || !/[A-Za-z]/.test(user) || looksLikeName(user, true)) return m
1046      return entropy(user) >= cfg.contextMinEntropy ? `${head}${mint('SECRET', user)}@` : m
1047    })
1048  }
1049  if (out.includes('=')) {
1050    out = out.replace(URL_PARAM, (m, head: string, value: string) => (urlParamSecret(value, cfg) ? head + mint('SECRET', value) : m))
1051  }
1052  return out
1053}
1054
1055function urlParamSecret(raw: string, cfg: Config): boolean {
1056  if (cfg.allow.has(raw)) return false
1057  let v = raw
1058  try {
1059    v = decodeURIComponent(raw)
1060  } catch {
1061    // A broken escape: judge it as it stands.
1062  }
1063  if (cfg.allow.has(v) || v.length < 12) return false
1064  if ([/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(v)).length < 2) return false
1065  if (looksLikeName(v, true)) return false
1066  return entropy(v) >= cfg.contextMinEntropy
1067}
1068
1069function scrubCommandLines(text: string, cfg: Config): string {
1070  let out = text
1071  if (/-(?:pass|api|token|client|auth|access)/.test(out)) {
1072    out = out.replace(CLI_FLAG, (m, head: string, quote: string, v: string) => {
1073      if (/^[A-Z][A-Z0-9_]*$/.test(v) || /^[A-Za-z_]+=/.test(v) || /^[A-Za-z]+(?:[-_][A-Za-z]+)+$/.test(v)) return m
1074      return pairOut(cfg, head, v, quote)
1075    })
1076  }
1077  if (/\s-(?:u|U|-user|-proxy-user)\b/.test(out)) {
1078    out = out.replace(CLI_USERPASS, (m, head: string, v: string) => (DIGITS_ONLY.test(v) ? m : pairOut(cfg, head, v)))
1079  }
1080  if (/mysql|mariadb/.test(out)) out = out.replace(MYSQL_P, (_m, head: string, v: string) => pairOut(cfg, head, v))
1081  if (out.includes(':')) out = out.replace(PGPASS, (_m, head: string, v: string) => pairOut(cfg, head, v))
1082  if (out.includes('password')) out = out.replace(NETRC, (_m, head: string, v: string) => pairOut(cfg, head, v))
1083  if (out.includes('gh')) out = out.replace(GH_SECRET, (_m, head: string, _q: string, v: string) => pairOut(cfg, head, v))
1084  if (CMD_PW_GATE.test(out)) {
1085    for (const re of CMD_PW) out = out.replace(re, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1086  }
1087  if (out.includes('pass:')) out = out.replace(OPENSSL_PASS, (_m, head: string, v: string) => pairOut(cfg, head, v))
1088  if (out.includes('smbclient')) out = out.replace(SMB_USER, (_m, head: string, v: string) => pairOut(cfg, head, v))
1089  if (out.includes('htpasswd')) out = out.replace(HTPASSWD, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1090  if (/net[ \t]+user/i.test(out)) out = out.replace(NET_USER, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1091  if (out.includes('rabbitmqctl')) out = out.replace(RABBIT_USER, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1092  return out
1093}
1094
1095function scrubAssignments(text: string, cfg: Config): string {
1096  let out = text
1097  // A value under a key that says it is public is left: a publishable key.
1098  const value = (head: string, key: string, v: string, tail = ''): string => {
1099    if (/public|publishable/i.test(key)) return head + v + tail
1100    if (PUBLIC_PREFIX.test(v) && !/secret|private/i.test(key) && isPublicId(v, false, cfg)) return head + v + tail
1101    if (shortKeyStatus(key, v)) return head + v + tail
1102    return pairOut(cfg, head, v, tail)
1103  }
1104  // A value whose name sits in a sibling field.
1105  const sibling = (head: string, name: string, v: string, tail = ''): string => {
1106    if (PW_NAME.test(name)) return value(head, name, v, tail)
1107    if (KEY_NAME.test(name) && !/public|publishable/i.test(name) && looksSecret(v, 'key=', cfg)) return head + mint('SECRET', v) + tail
1108    return head + v + tail
1109  }
1110  if (/pass|secret|key|token|pw/i.test(out)) {
1111    out = out.replace(STRONG_QUOTED, (_m, head: string, _q: string, key: string, quote: string, v: string) => value(head + quote, key, v, quote))
1112    out = out.replace(APP_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1113    out = out.replace(STRONG_LINE, (_m, head: string, key: string, v: string) => value(head, key, v))
1114    out = out.replace(ENV_PASS, (_m, head: string, _k: string, v: string) => value(head, '', v))
1115    out = out.replace(BLOCK_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1116    out = out.replace(KV_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1117  }
1118  if (PW_SYMBOL.test(out)) {
1119    out = out.replace(QUOTED_VALUE, (m, q: string, v: string, offset: number, whole: string) => {
1120      if (!looksGenerated(v) || isStandIn(v) || cfg.allow.has(v)) return m
1121      // A MIME boundary is random by design and public.
1122      if (/boundary=$/i.test(whole.slice(Math.max(0, offset - 9), offset))) return m
1123      return q + mint('SECRET', v) + q
1124    })
1125  }
1126  if (/password|identified/i.test(out)) out = out.replace(SQL_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head + "'", v, "'"))
1127  if (out.includes('variable')) out = out.replace(HCL_DEFAULT, (_m, head: string, v: string) => pairOut(cfg, head, v, '"'))
1128  if (out.includes('"value"')) {
1129    out = out.replace(JSON_NAME_VALUE, (_m, head: string, name: string, v: string) => sibling(head, name, v, '"'))
1130    out = out.replace(JSON_VALUE_NAME, (_m, head: string, v: string, tail: string, name: string) => sibling(head, name, v, tail))
1131    out = out.replace(TF_OUTPUT, (_m, head: string, name: string, v: string) => (PW_NAME.test(name) ? value(head, name, v, '"') : _m))
1132  }
1133  if (/"random_(?:password|string)"/.test(out)) {
1134    out = out.replace(TF_RESULT, (m, head: string, v: string) => (WHOLE_TAG.test(v) || cfg.allow.has(v) ? m : `${head}${mint('SECRET', v)}"`))
1135  }
1136  if (/-[ \t]+name:/.test(out)) {
1137    out = out.replace(YAML_NAME_VALUE, (_m, indent: string, head: string, name: string, q: string, v: string) => sibling(indent + head, name, v, q))
1138  }
1139  if (/INSERT[ \t]+INTO/i.test(out)) out = scrubSqlInserts(out, cfg)
1140  return out
1141}
1142
1143// The VALUES rows after an INSERT whose columns name a key or a password:
1144// the value in that column is hidden. Bounded to 100 rows and 20,000
1145// characters per INSERT. Quoted values only; a number is never a key.
1146function scrubSqlInserts(text: string, cfg: Config): string {
1147  const ranges: [number, number][] = []
1148  for (const m of text.matchAll(SQL_INSERT)) {
1149    const names = m[1]!.split(',').map((c) => c.trim().replace(/^[`"[]|[`"\]]$/g, ''))
1150    const kinds = names.map((n) => (PW_NAME.test(n) ? 'pw' : KEY_NAME.test(n) && !/(?:^|_)id$|public/i.test(n) ? 'key' : ''))
1151    if (!kinds.some(Boolean)) continue
1152    const limit = Math.min(text.length, m.index! + m[0].length + 20_000)
1153    let i = m.index! + m[0].length
1154    const skipSpace = (): void => {
1155      while (i < limit && /\s/.test(text[i]!)) i++
1156    }
1157    for (let row = 0; row < 100 && text[i] === '('; row++) {
1158      i++
1159      for (let col = 0; i < limit && text[i] !== ')'; col++) {
1160        skipSpace()
1161        if (text[i] === "'") {
1162          const from = ++i
1163          while (i < limit && !(text[i] === "'" && text[i + 1] !== "'")) i += text[i] === "'" ? 2 : 1
1164          const v = text.slice(from, i)
1165          i++
1166          const kind = kinds[col]
1167          if (kind && v.length >= 3 && !cfg.allow.has(v) && (kind === 'pw' ? !isStandIn(v) : looksSecret(v, 'key=', cfg))) ranges.push([from, from + v.length])
1168        } else {
1169          while (i < limit && text[i] !== ',' && text[i] !== ')') i++
1170        }
1171        skipSpace()
1172        if (text[i] === ',') i++
1173        else break
1174      }
1175      if (text[i] !== ')') break
1176      i++
1177      skipSpace()
1178      if (text[i] !== ',') break
1179      i++
1180      skipSpace()
1181    }
1182  }
1183  if (ranges.length === 0) return text
1184  const parts: string[] = []
1185  let last = 0
1186  for (const [from, to] of ranges) {
1187    if (from < last) continue
1188    parts.push(text.slice(last, from), mint('SECRET', text.slice(from, to)))
1189    last = to
1190  }
1191  parts.push(text.slice(last))
1192  return parts.join('')
1193}
1194
1195function cookieSecret(raw: string, cfg: Config): boolean {
1196  if (cfg.allow.has(raw) || raw.includes('[REDACTED-')) return false
1197  const m = raw.match(COOKIE_VALUE)
1198  if (!m) return false
1199  const body = m[1]!
1200  if (looksLikeName(body, true)) return false
hooks/keychain.js 653 lines
1// The macOS Keychain backend: secrets live in the Keychain as generic
2// passwords, not in a plaintext .env, and Claude only ever holds placeholders.
3//
4// Every call runs /usr/bin/security by argv through $.process.run, never a
5// shell, so a service name can't run as code. The full path, not `security`:
6// a `security` earlier on PATH would be handed the key. Nothing here touches
7// the vault; the caller hands what it reads to keepSecret, as the box does.
8
9export const SECURITY = '/usr/bin/security'
10
11// What `security find-*-password` exits with when no item matches
12export const NOT_FOUND = 44
13
14// The vault can only find a value again where it turns up when it is at least
15// this long (detect.ts indexes values by their first 8 characters)
16export const MIN_HIDDEN = 8
17
18// The redactor's rule (redact.ts SHELL_ACTIVE) for a value it won't put into
19// a Bash command, since it could run there as code. A test pins the two together.
20export const SHELL_ACTIVE = /[\s$`"'\\;&|<>()*?[\]{}#~]|[\x00-\x1f\x7f]/
21
22// Which vault this machine has: 'mac' (the Keychain), 'windows' (Credential
23// Manager), 'other', or 'unknown' when it can't be told. The mods API has no
24// platform call: Windows says so in OS; anything else answers uname, run by
25// its full path so nothing on PATH stands in for it. Where neither uname is
26// there (NixOS, Termux), every Mac has macOS's version file; only if that
27// can't be checked either is the platform unknown.
28export const MACOS_VERSION_FILE = '/System/Library/CoreServices/SystemVersion.plist'
29export async function platformOf($) {
30  try {
31    if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
32  } catch {}
33  for (const uname of ['/usr/bin/uname', '/bin/uname']) {
34    try {
35      const r = await $.process.run([uname, '-s'])
36      const name = r.exitCode === 0 ? r.stdout.trim() : ''
37      if (name) return name === 'Darwin' ? 'mac' : 'other'
38    } catch {}
39  }
40  try {
41    return (await $.fs.exists(MACOS_VERSION_FILE)) ? 'mac' : 'other'
42  } catch {
43    return 'unknown'
44  }
45}
46
47// Where the box saves: keychain, dotenv, or unknown when `auto` (and any value
48// that is neither) meets a platform that can't be told. The box saves nowhere then.
49// `keychain` is the system's vault: the Keychain on macOS, Credential Manager
50// on Windows.
51export function boxBackend(choice, platform) {
52  if (choice === 'keychain' || choice === 'dotenv') return choice
53  return platform === 'mac' || platform === 'windows' ? 'keychain' : platform === 'other' ? 'dotenv' : 'unknown'
54}
55
56// A service or account goes into argv and into a command you paste into a
57// shell: one line of printable text, and not huge.
58const MAX_NAME = 256
59export function badName(label, value) {
60  if (/[\x00-\x1f\x7f-\x9f]/.test(value)) return `The ${label} holds a control character.`
61  if (value.length > MAX_NAME) return `The ${label} is longer than ${MAX_NAME} characters.`
62  return ''
63}
64
65// The comment on every item the mod saves, so list_key_names can find them
66export const BOX_COMMENT = 'secret-box'
67
68// -U updates the item when it is already there; -j tags it as the mod's. The
69// value sits in argv while the command runs, where `ps` and any mod hooked on
70// process.run can see it.
71export function addArgv(service, account, value) {
72  return [SECURITY, 'add-generic-password', '-U', '-s', service, '-a', account, '-j', BOX_COMMENT, '-w', value]
73}
74
75// The command you run to add an item by hand. With -w last, `security`
76// prompts for the value, so the key never lands in your shell history; -U
77// replaces an item that is already there.
78export function addHint(service, account) {
79  return `security add-generic-password -U -s ${shellWord(service)} -a ${account ? shellWord(account) : '"$USER"'} -w`
80}
81
82// What Claude writes in a Bash command for a value it can't hold as a
83// placeholder: the shell reads the item as the command runs, so the value
84// never enters the command text. -w prints a printable value as it is.
85export function readCommand(service, account) {
86  return `"$(${SECURITY} find-generic-password -s ${shellWord(service)}${account ? ` -a ${shellWord(account)}` : ''} -w)"`
87}
88
89function shellWord(s) {
90  return /^[A-Za-z0-9_.,:@%+=\/-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`
91}
92
93// The account the box saves under: the keychainAccount setting, else your
94// macOS user name, as `security add-generic-password -a "$USER"` would.
95export async function defaultAccount($, options) {
96  return String((options && options.keychainAccount) || '').trim() || (await $.env.get('USER')) || ''
97}
98
99export async function saveToKeychain($, service, account, value) {
100  const r = await run($, addArgv(service, account, value))
101  if (r.exitCode !== 0) throw new Error(`security exited ${r.exitCode}`)
102}
103
104// A rejected $.process.run may quote its command, and the command may hold
105// the key, so the reason is put in words of our own.
106async function run($, argv) {
107  try {
108    return await $.process.run(argv)
109  } catch (err) {
110    throw new Error(/ENOENT|not found/i.test(String((err && err.message) || err))
111      ? `${SECURITY} is missing: the Keychain backend needs macOS`
112      : 'security did not finish')
113  }
114}
115
116// ------------------------------------------------------------- reading an item
117
118// A read is the subcommand's kind, its match options in the order given, as
119// [flag, value] pairs, and the keychain files it searches. use_secret reads by
120// service, and by account when one is given: without -a an item matches by its
121// service alone, the way the items you already have were saved.
122export function serviceRead(service, account) {
123  return { kind: 'generic', criteria: [['-s', service], ...(account ? [['-a', account]] : [])], keychains: [] }
124}
125
126// Always -g, whatever a command used: it says plainly how the password is
127// written, where -w prints plain text and hex alike.
128export function findArgv(read) {
129  return [SECURITY, `find-${read.kind}-password`, ...read.criteria.flat(), '-g', ...read.keychains]
130}
131
132// `security -g` prints the password on stderr in one of three shapes:
133//   password: "text"                       every byte printable, no " or \
134//   password: 0x6C696E65310A  "line1\012"   anything else: hex, then an escaped copy
135//   password:                              an empty password
136// `-w` prints the text and a newline, or, when any byte is not printable
137// ASCII, the bytes as lowercase hex and a newline.
138const PASSWORD = /^password: (?:"(.*)"|0x((?:[0-9A-F]{2})*) {2}".*"|)$/m
139
140// Reads one item: { value, printed }, or { exitCode } when security found no
141// item or could not read it. `printed` is every way security can print the
142// password, so each can be hidden; `value` is its text, undefined when the
143// bytes are not UTF-8. Nothing security prints goes anywhere else.
144export async function readKeychain($, read) {
145  const r = await run($, findArgv(read))
146  if (r.exitCode !== 0) return { exitCode: r.exitCode }
147  // A cut-off answer could hold half a password
148  if (r.isStdoutTruncated || r.isStderrTruncated) throw new Error("security's answer was cut off")
149  const m = String(r.stderr).match(PASSWORD)
150  if (!m) throw new Error('security printed no password line')
151  if (m[2] === undefined) return { value: m[1] ?? '', printed: m[1] ? [m[1]] : [] }
152  return { value: utf8(m[2]), printed: printedForms(m[2]) }
153}
154
155// The ways a password security shows as hex can turn up: the text, each of its
156// lines long enough to hide (a command may print one line of a PEM), -w's
157// lowercase hex, -g's uppercase hex, and -g's escaped copy, where \ and every
158// byte that is not printable ASCII is written \ooo in octal.
159function printedForms(hex) {
160  const bytes = (hex.match(/../g) || []).map((b) => parseInt(b, 16))
161  const escaped = bytes.map((n) => (n >= 0x20 && n < 0x7f && n !== 0x5c ? String.fromCharCode(n) : '\\' + n.toString(8).padStart(3, '0'))).join('')
162  const text = utf8(hex)
163  const lines = text && text.includes('\n') ? text.split(/\r?\n/).filter((l) => l.length >= MIN_HIDDEN) : []
164  return [...new Set([text, ...lines, hex.toLowerCase(), hex, escaped])].filter(Boolean)
165}
166
167// Bytes to text by way of %XX, since the hooks run with no TextDecoder
168function utf8(hex) {
169  try {
170    return decodeURIComponent(hex.replace(/../g, '%$&'))
171  } catch {
172    return undefined
173  }
174}
175
176// ---------------------------------------------------------------- .env pointers
177
178// A pointer stands in a .env file where a key's value would, and names the
179// Keychain item that holds it: keychain:SERVICE or keychain:SERVICE?account=ACCOUNT.
180// Printable ASCII only; anything outside the plain set is percent-encoded
181// (%20 for a space). bin/keychain-env reads pointers by the same rule.
182const SEG = '(?:[A-Za-z0-9._~:@/+,;=!*-]|%(?:2[0-9A-Fa-f]|[3-6][0-9A-Fa-f]|7[0-9A-Ea-e]))+'
183const POINTER = new RegExp(`^keychain:(${SEG})(?:\\?account=(${SEG}))?$`)
184const unescapePointer = (s) => s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
185const escapePointer = (s) => s.replace(/[^A-Za-z0-9._~:@/+,;=!*-]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))
186
187// { service, account? } for a pointer, undefined for anything else
188export function parsePointer(value) {
189  const m = POINTER.exec(String(value))
190  return m ? { service: unescapePointer(m[1]), ...(m[2] ? { account: unescapePointer(m[2]) } : {}) } : undefined
191}
192
193// A service or account shaped like a key, which the detector leaves alone on
194// purpose (it could be a hash or an id): 16+ hex digits, a UUID, or 20+
195// letters and digits mixed with no / . - _ or space between them. Datadog,
196// Algolia and Twilio keys and UUID API keys look like this; names made the
197// usual way (myapp/STRIPE_SECRET_KEY, openai, acme.prod/DB_PASSWORD, a login
198// name) don't.
199export function looksLikeKey(name) {
200  const s = String(name)
201  if (/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/.test(s)) return true
202  return s.split(/[/._\- ]+/).some((run) => /^[0-9a-fA-F]{16,}$/.test(run) || (run.length >= 20 && /[A-Za-z]/.test(run) && /[0-9]/.test(run)))
203}
204
205// Whether the Keychain holds the item a pointer names: true, false (security
206// exits 44), or undefined when that can't be told. Attributes only, no -g or
207// -w, so no password is read and nothing prompts; what it prints stays here.
208export async function itemExists($, { service, account }) {
209  try {
210    const r = await $.process.run([SECURITY, 'find-generic-password', '-s', service, ...(account ? ['-a', account] : [])])
211    return r.exitCode === 0 ? true : r.exitCode === NOT_FOUND ? false : undefined
212  } catch {
213    return undefined
214  }
215}
216
217// The pointer to an item, or undefined when a name holds a character a
218// pointer can't carry (anything but printable ASCII)
219export function pointerTo(service, account) {
220  if (!/^[\x20-\x7e]+$/.test(service) || (account && !/^[\x20-\x7e]+$/.test(account))) return undefined
221  return `keychain:${escapePointer(service)}${account ? `?account=${escapePointer(account)}` : ''}`
222}
223
224// bin/keychain-env reads .env files in the C locale, where a blank is one of
225// space, tab, CR, VT and FF and every other character (U+2028, U+00A0) is
226// text. These follow it exactly, so the mod loads every pointer the runner
227// will resolve; tests/env-cases.ts pins the two together.
228const trimBlanks = (s) => s.replace(/^[ \t\r\v\f]+|[ \t\r\v\f]+$/g, '')
229const DQ = /^"([^"]*)"(?:[ \t\r\v\f]+#[^\n]*)?$/
230const SQ = /^'([^']*)'(?:[ \t\r\v\f]+#[^\n]*)?$/
231
232// A .env value as dotenv libraries read it: in quotes, the text inside them;
233// bare, the text before a ` #` comment
234export function envValue(raw) {
235  const v = trimBlanks(String(raw))
236  const q = DQ.exec(v) || SQ.exec(v)
237  return q ? q[1] : trimBlanks(v.replace(/[ \t\r\v\f]+#[^\n]*$/, ''))
238}
239
240// False for a value whose opening quote doesn't close on its line: a
241// multi-line value (a PEM key) whose rest sits on the lines below
242export function quoteCloses(raw) {
243  const v = trimBlanks(String(raw))
244  return !/^["']/.test(v) || DQ.test(v) || SQ.test(v)
245}
246
247// Each NAME=value line of a .env file, as [name, value, raw value]
248export function envAssignments(text) {
249  const out = []
250  for (const line of String(text).split('\n')) {
251    const m = /^[ \t\r\v\f]*(?:export[ \t\r\v\f]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t\r\v\f]*=[ \t\r\v\f]*([^\n]*)$/.exec(line)
252    if (m) out.push([m[1], envValue(m[2]), m[2]])
253  }
254  return out
255}
256
257// The pointers in a .env file, as [name, { service, account? }]
258export function envPointers(text) {
259  return envAssignments(text).flatMap(([name, value]) => {
260    const pointer = parsePointer(value)
261    return pointer ? [[name, pointer]] : []
262  })
263}
264
265// --------------------------------------------------------------- listing names
266
267// The generic passwords in `security dump-keychain` output. Without -d it
268// prints attributes only, never a password. Each item gives its service,
269// account, label and comment. An attribute is "text", or 0xHEX and an escaped
270// copy when it holds a byte that isn't printable, or <NULL>.
271export function dumpedItems(text) {
272  const items = []
273  let item
274  for (const line of String(text).split('\n')) {
275    if (line.startsWith('keychain: ')) items.push((item = {}))
276    else if (item && line.startsWith('class: ')) item.class = attribute(line.slice('class: '.length))
277    else if (item) {
278      // `    "acct"<blob>="..."`, and the label as `    0x00000007 <blob>="..."`
279      const m = /^\s+(?:"(\w{4})"|0x(00000007) )<\w+>=(.*)$/.exec(line)
280      // 0x00000007 is the label (the item's name in Keychain Access)
281      if (m) item[m[1] || 'labl'] = attribute(m[3])
282    }
283  }
284  return items
285    .filter((i) => i.class === 'genp' && i.svce)
286    .map((i) => ({ service: i.svce, account: i.acct ?? '', label: i.labl ?? '', comment: i.icmt ?? '' }))
287}
288
289function attribute(raw) {
290  const q = /^"(.*)"$/.exec(raw)
291  if (q) return q[1]
292  const h = /^0x((?:[0-9A-Fa-f]{2})*)(?: {2}".*")?$/.exec(raw)
293  return h ? utf8(h[1]) : undefined
294}
295
296// The generic passwords in the keychain search list, names only: the dump is
297// read here and never handed on
298export async function keychainItems($) {
299  const r = await run($, [SECURITY, 'dump-keychain'])
300  if (r.exitCode !== 0) throw new Error(`security exited ${r.exitCode}`)
301  return { items: dumpedItems(r.stdout), isCut: Boolean(r.isStdoutTruncated) }
302}
303
304// ---------------------------------------------- Keychain reads in a Bash command
305
306// The variables a read may name that the hook can know. $.env only reads
307// names spelled in the source, so these are the ones it fetches.
308export const READ_ENV = ['USER', 'LOGNAME', 'HOME']
309
310// The match options of each subcommand: the flags that take a value. -g and -w
311// take none; any other flag (-h) is one we don't follow.
312const MATCH = { generic: 'acCDGjls', internet: 'acCdDjlpPrst' }
313const READS = ['find-generic-password', 'find-internet-password']
314const MENTION = /find-(?:generic|internet)-password|dump-keychain|keychain-env/g
315// On Windows, bin/credman (Git Bash) and bin/credman.ps1 are what read an item
316const CREDMAN_MENTION = /credman|keychain-env/gi
317const isCredman = (w) => typeof w === 'string' && /(?:^|[\\/])credman(?:\.ps1)?$/i.test(w)
318const unknown = (why) => ({ unknown: why })
319const unknowable = unknown('a word in it is only known when it runs')
320
321// The runner, by name or by any path to it (C:\x\keychain-env too)
322const RUNNER = 'keychain-env'
323const isRunner = (w) => typeof w === 'string' && (w === RUNNER || w.endsWith('/' + RUNNER) || w.endsWith('\\' + RUNNER))
324// Commands that run the command they are given, so a runner after one is run
325const WRAPPERS = ['sudo', 'env', 'exec', 'command', 'nohup', 'nice', 'xargs', 'bash', 'sh', 'zsh']
326
327// A keyword that starts a command inside if, for or while is not the command
328const command = (words) => {
329  let k = 0
330  while (['do', 'then', 'else', 'elif', '!', 'time'].includes(words[k])) k++
331  return words.slice(k)
332}
333
334// The Keychain reads a Bash command makes, each a read for readKeychain, a
335// runner's { envFiles } (the .env files bin/keychain-env will resolve), or
336// { unknown: why } where we can't know exactly which item it reads or what it
337// prints: a word only the shell can know ($VAR, a glob, $(...)), a prefix
338// (sudo, env, VAR=x) that could change whose Keychain it reads, a relative
339// keychain file, `security -i` or `dump-keychain`, a runner whose .env can't
340// be known, or a read that isn't a command of its own (inside quotes for
341// `sh -c`). `env` holds READ_ENV's values. With `windows`, the reads are
342// bin/credman's (credRead's { kind: 'credman', service, account }), and a
343// `security` command is nothing: Windows has none.
344export function keychainReads(cmd, env = {}, { windows = false } = {}) {
345  const text = String(cmd || '')
346  const ctx = { text, env, commands: [], comments: [] }
347  lex(text, 0, '', ctx)
348  const reads = new Map()
349  const add = (read) => reads.set(JSON.stringify(read), read)
350  const readsOf = windows ? credmanReadOf : readOf
351  const mention = windows ? CREDMAN_MENTION : MENTION
352  const isMention = windows ? (w) => isCredman(w) || isRunner(w) : (w) => READS.includes(w) || w === 'dump-keychain' || isRunner(w)
353  let seen = 0
354  for (const words of ctx.commands) {
355    seen += words.filter(isMention).length
356    for (const read of commandReads(words, readsOf)) add(read)
357  }
358  // A mention outside a comment that isn't a word of its own: inside quotes, or glued to others
359  let code = ''
360  let from = 0
361  for (const [start, end] of ctx.comments) {
362    code += text.slice(from, start)
363    from = end
364  }
365  code += text.slice(from)
366  if ((code.match(mention) || []).length > seen) add(unknown('part of it could not be parsed'))
367  // The runner reads ./.env from the shell's folder: a cd before it moves that
368  if (ctx.commands.some((w) => ['cd', 'pushd', 'popd'].includes(command(w)[0]))) {
369    for (const [key, read] of reads) {
370      if (read.envFiles) {
371        reads.delete(key)
372        add(unknown('it changes folder, so which .env keychain-env reads is unknown'))
373      }
374    }
375  }
376  return [...reads.values()]
377}
378
379// What one simple command reads: the runner's .env files and the command it
380// runs, or the command's own read
381function commandReads(all, readsOf) {
382  const words = command(all)
383  if (isRunner(words[0])) return runnerReads(words.slice(1), readsOf)
384  if (words.slice(1).some(isRunner) && (words[0] === null || WRAPPERS.includes(String(words[0]).split(/[\\/]/).pop().replace(/\.exe$/i, '')))) {
385    return [unknown('something runs keychain-env in a way secret-box can\'t follow')]
386  }
387  const read = readsOf(words)
388  return read ? [read] : []
389}
390
391// keychain-env [--env-file PATH]... -- COMMAND: the .env files it resolves
392// (./.env when none is named), then whatever COMMAND reads in turn. Anything
393// else is what the runner itself refuses.
394function runnerReads(args, readsOf) {
395  const envFiles = []
396  for (let k = 0; k < args.length; k++) {
397    const a = args[k]
398    if (a === null) return [unknowable]
399    if (a === '--') return [{ envFiles: envFiles.length ? envFiles : ['.env'] }, ...commandReads(args.slice(k + 1), readsOf)]
400    if (a === '--env-file' && typeof args[k + 1] === 'string') envFiles.push(args[++k])
401    else if (a.startsWith('--env-file=')) envFiles.push(a.slice('--env-file='.length))
402    else return [a === '--env-file' ? unknowable : unknown(`keychain-env is given ${a}`)]
403  }
404  return [unknown('keychain-env is run without --')]
405}
406
407// What one simple command reads from the Keychain: nothing (undefined), a
408// read, or { unknown }. A read is `security [-lqv] [-p prompt]
409// find-*-password [options] [keychain...]`, its options read as getopt does:
410// bundled (`-wa NAME`), attached (`-sNAME`), and ended by the first word that
411// is not one, which starts the keychain files.
412function readOf(words) {
413  const at = words.findIndex((w) => READS.includes(w) || w === 'dump-keychain')
414  const runsSecurity = typeof words[0] === 'string' && /(?:^|\/)security$/.test(words[0])
415  if (at < 0 && !runsSecurity) return undefined
416  if (words[0] !== 'security' && words[0] !== SECURITY) {
417    // sudo security, env security, $SEC find-...: security run in a way we can't
418    // follow. `grep find-generic-password` only names the subcommand.
419    const before = words.slice(0, Math.max(at, 1))
420    return before.some((w) => w === null || /(?:^|\/)security$/.test(w)) ? unknown('something other than security runs it') : undefined
421  }
422  let k = 1
423  for (; k < words.length; k++) {
424    if (words[k] === '-p') k++
425    else if (/^-[hilqv]+$/.test(words[k] ?? '')) {
426      if (words[k].includes('i')) return unknown('it runs security interactively')
427    } else break
428  }
429  // No command: security reads its commands from its input
430  if (k >= words.length) return unknown('it runs security without a command')
431  if (words[k] === null) return unknowable
432  if (k !== at) return undefined // another command (add-generic-password ...)
433  if (words[at] === 'dump-keychain') return unknown('it dumps the keychain')
434  const kind = words[at].slice('find-'.length, -'-password'.length)
435  const args = words.slice(at + 1)
436  const criteria = []
437  for (k = 0; k < args.length; k++) {
438    const a = args[k]
439    if (a === null) return unknowable
440    if (a === '--') {
441      k++
442      break
443    }
444    if (a[0] !== '-' || a === '-') break
445    for (let p = 1; p < a.length; p++) {
446      if (a[p] === 'g' || a[p] === 'w') continue
447      if (!MATCH[kind].includes(a[p])) return unknown(`it uses -${a[p]}`)
448      const value = p + 1 < a.length ? a.slice(p + 1) : args[++k]
449      if (value === undefined) return unknown(`its -${a[p]} has no value`)
450      if (value === null) return unknowable
451      criteria.push([`-${a[p]}`, value])
452      break
453    }
454  }
455  const keychains = args.slice(k)
456  if (keychains.includes(null)) return unknowable
457  // A relative path is found from the shell's folder, which the hook can't see
458  if (keychains.some((f) => !f.startsWith('/'))) return unknown('it names a keychain file by a relative path')
459  return { kind, criteria, keychains }
460}
461
462// What one simple command reads from Windows Credential Manager: nothing
463// (undefined), a read, or { unknown }. A read is bin/credman, or credman.ps1
464// run by Windows PowerShell, with `get` and its -s/-Service and -a/-Account
465// (PowerShell takes any prefix of a parameter's name, and -Name:value). `read`
466// takes its names on stdin, which can't be known. The other commands print
467// names at most.
468const POWERSHELL = /^(?:.*[\\/])?(?:powershell|pwsh)(?:\.exe)?$/i
469function credmanReadOf(words) {
470  const at = words.findIndex(isCredman)
471  if (at < 0) return undefined
472  const before = words.slice(0, at)
473  if (before.includes(null)) return unknown('something other than credman runs it')
474  // `cat bin/credman`, `grep credman`: the script is only named
475  if (before.length && !POWERSHELL.test(before[0])) return undefined
476  const sub = words[at + 1]
477  if (sub === null) return unknowable
478  if (typeof sub !== 'string') return undefined
479  if (/^read$/i.test(sub)) return unknown('it reads which item from its input')
480  if (!/^get$/i.test(sub)) return undefined
481  let service
482  let account
483  const args = words.slice(at + 2)
484  for (let k = 0; k < args.length; k++) {
485    const a = args[k]
486    if (a === null) return unknowable
487    const m = /^-([A-Za-z]+)(?::(.*))?$/.exec(a)
488    if (!m) return unknown(`credman is given ${a}`)
489    const flag = m[1].toLowerCase()
490    const isService = flag === 's' || 'service'.startsWith(flag)
491    const isAccount = flag === 'a' || 'account'.startsWith(flag)
492    if (!isService && !isAccount) return unknown(`it uses -${m[1]}`)
493    const value = m[2] !== undefined ? m[2] : args[++k]
494    if (value === undefined) return unknown(`its -${m[1]} has no value`)
495    if (value === null) return unknowable
496    if (isService) service = value
497    else account = value
498  }
499  if (!service) return undefined
500  return { kind: 'credman', service, ...(account ? { account } : {}) }
501}
502
503// Splits a command into the words of each simple command, the way zsh and
504// bash hand them over, as far as we can know them: a word is its text, or
505// null where the shell turns it into something we don't hold. The commands
506// inside $( ) and backticks are read as commands too. Returns where it stopped.
507function lex(s, i, stop, ctx) {
508  let words = []
509  let word // undefined: none started; null: one we can't know
510  let drop = false // the next word is a redirection's target, not an argument
511  const put = (piece) => { word = word === null || piece === null ? null : (word ?? '') + piece }
512  const endWord = () => {
513    if (word === undefined) return
514    if (drop) drop = false
515    else words.push(word)
516    word = undefined
517  }
518  const endCommand = () => {
519    endWord()
520    if (words.length) ctx.commands.push(words)
521    words = []
522    drop = false
523  }
524  while (i < s.length) {
525    const c = s[i]
526    if (c === stop) {
527      endCommand()
528      return i + 1
529    }
530    if (c === '\\') {
531      // A line continuation joins two lines; any other escaped character is itself
532      if (s[i + 1] !== '\n') put(s[i + 1] ?? '')
533      i += 2
534    } else if (c === ' ' || c === '\t') {
535      endWord()
536      i++
537    } else if (c === '#' && word === undefined) {
538      const start = i
539      while (i < s.length && s[i] !== '\n') i++
540      ctx.comments.push([start, i])
541    } else if (c === '<' || c === '>' || (c === '&' && s[i + 1] === '>')) {
542      // A redirection (2>/dev/null, &>log, 2>&1): its fd and its target aren't arguments
543      if (typeof word === 'string' && /^\d+$/.test(word)) word = undefined
544      endWord()
545      drop = true
546      i++
547      while (i < s.length && '<>&|-'.includes(s[i])) i++
548    } else if (';&|\n()'.includes(c)) {
549      endCommand()
550      i++
551    } else if (c === "'") {
552      const end = s.indexOf("'", i + 1)
553      put(end < 0 ? null : s.slice(i + 1, end))
554      i = end < 0 ? s.length : end + 1
555    } else if (c === '"') {
556      i = quoted(s, i + 1, ctx, put)
557    } else if (c === '$' || c === '`') {
558      i = expand(s, i, ctx, put, false)
559    } else if (c === '~' && word === undefined) {
560      // ~ and ~/x are the home folder, unless the command changes HOME; ~name is someone else's
561      const tilde = /^~[\w.+-]*/.exec(s.slice(i))[0]
562      put(tilde === '~' && !isSet('HOME', ctx.text) ? (ctx.env.HOME ?? null) : null)
563      i += tilde.length
564    } else if ('*?[]{}'.includes(c) || (c === '=' && word === undefined)) {
565      // Globs, braces and zsh's =cmd become text we can't know
566      put(null)
567      i++
568    } else {
569      put(c)
570      i++
571    }
572  }
573  endCommand()
574  return i
575}
576
577// The inside of "...": $ and backticks expand, a backslash escapes only
578// $ ` " \ and a newline. An unterminated quote can't be known.
579function quoted(s, i, ctx, put) {
580  let piece = ''
581  const add = (p) => { piece = piece === null || p === null ? null : piece + p }
582  while (i < s.length && s[i] !== '"') {
583    if (s[i] === '\\' && '$`"\\\n'.includes(s[i + 1] ?? 'x')) {
584      if (s[i + 1] !== '\n') add(s[i + 1])
585      i += 2
586    } else if (s[i] === '$' || s[i] === '`') {
587      i = expand(s, i, ctx, add, true)
588    } else {
589      add(s[i])
590      i++
591    }
592  }
593  put(i < s.length ? piece : null)
594  return i + 1
595}
596
597// Whether the command could change a variable: its name as a word of its own
598// anywhere (`USER=x`, `for USER in`, `read USER`, `unset USER`), not as $NAME
599// or ${NAME}. A name that only appears in some other sense makes it unknown too.
600function isSet(name, text) {
601  return new RegExp(`(?<![\\w$]|\\$\\{)${name}(?!\\w)`).test(text)
602}
603
604// $NAME, ${NAME}, $( ) and backticks. Only READ_ENV's names are known, and
605// only while the command can't change them. The command inside $( ) or
606// backticks is read too; what it prints can't be known.
607function expand(s, i, ctx, put, isQuoted) {
608  if (s[i] === '`') {
609    put(null)
610    return lex(s, i + 1, '`', ctx)
611  }
612  const next = s[i + 1]
613  if (next === '(') {
614    put(null)
615    return lex(s, i + 2, ')', ctx)
616  }
617  if (next === "'") {
618    // $'...' turns escapes into bytes
619    put(null)
620    const end = s.indexOf("'", i + 2)
621    return end < 0 ? s.length : end + 1
622  }
623  if (next === '"') {
624    // $"..." is translated text
625    put(null)
626    return quoted(s, i + 2, ctx, () => {})
627  }
628  const m = /^\$(?:\{(\w+)\}|([A-Za-z_]\w*))/.exec(s.slice(i, i + 130))
629  if (m) {
630    const name = m[1] || m[2]
631    const end = i + m[0].length
632    // zsh changes an unbraced $USER:u (modifiers) and $USER[1] (subscripts)
633    const isChanged = m[2] !== undefined && (s[end] === '[' || (s[end] === ':' && /[A-Za-z]/.test(s[end + 1] ?? '')))
634    let value = READ_ENV.includes(name) && !isChanged && !isSet(name, ctx.text) ? ctx.env[name] : undefined
635    // Unquoted, bash splits a value on IFS and globs it, and zsh does neither
636    if (value !== undefined && !isQuoted && (/[\s*?[\]]/.test(value) || /IFS/.test(ctx.text))) value = undefined
637    put(value ?? null)
638    return end
639  }
640  if (next === '{') {
641    put(null)
642    const end = s.indexOf('}', i)
643    return end < 0 ? s.length : end + 1
644  }
645  if (/[\w?$!#*@-]/.test(next ?? '')) {
646    put(null)
647    return i + 2
648  }
649  // A $ before a space, a quote or the end is itself
650  put('$')
651  return i + 1
652}
653
hooks/credman.js 158 lines
1// The Windows Credential Manager backend: secrets live in Credential Manager
2// as generic credentials, not in a plaintext .env, and Claude only ever holds
3// placeholders. It mirrors keychain.js: the service is the credential's target
4// name, the account its user name, and the mod tags what it saves with the
5// comment `secret-box`.
6//
7// Every call runs bin/credman.ps1 under Windows PowerShell, by its full path
8// and by argv through $.process.run, never a shell. The names and the value go
9// in on stdin as hex lines, so none of them sits in a command line (any
10// process of this user can read one) and no quoting can go wrong. Nothing here
11// touches the vault; the caller hands what it reads to keepSecret.
12//
13// Unlike the Keychain, Credential Manager asks no one before a process of this
14// user reads an item. It keeps keys out of files and transcripts; it is no
15// wall against a program running as you.
16
17import { MIN_HIDDEN } from './keychain.js'
18
19// What credman.ps1 exits with (keychain.js's NOT_FOUND is 44 too)
20export const CRED_NOT_FOUND = 44
21export const CRED_OTHER_ACCOUNT = 5
22export const CRED_TOO_LARGE = 4
23
24// Windows PowerShell 5.1, which every Windows 10 and 11 has, by its full path:
25// a powershell.exe earlier on PATH would be handed the key
26export async function powershellPath($) {
27  let root = ''
28  try {
29    root = (await $.env.get('SystemRoot')) || ''
30  } catch {}
31  return `${(root || 'C:\\Windows').replace(/[\\/]+$/, '')}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
32}
33
34// A path as Git Bash takes it in a command: C:/x/y, since a backslash there is an escape
35export const bashPath = (path) => String(path).replace(/\\/g, '/')
36
37export const credmanScript = (root) => `${String(root).replace(/[\\/]+$/, '')}\\bin\\credman.ps1`
38// bin/credman, the script for Git Bash, by the path a command names it with
39export const credmanCommand = (root) => `${bashPath(String(root).replace(/[\\/]+$/, ''))}/bin/credman`
40
41export async function credmanArgv($, root, command) {
42  return [await powershellPath($), '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', credmanScript(root), command, '-Stdin']
43}
44
45// Text to the hex credman.ps1 reads, by way of its UTF-8 bytes (the hooks
46// have no TextEncoder). A lone surrogate is no text and throws.
47export function hexOf(text) {
48  const pieces = encodeURIComponent(String(text)).match(/%[0-9A-Fa-f]{2}|[^%]/g) || []
49  return pieces.map((c) => (c.length === 3 ? c.slice(1).toLowerCase() : c.charCodeAt(0).toString(16).padStart(2, '0'))).join('')
50}
51
52// Bytes to text by way of %XX, since the hooks run with no TextDecoder
53export function fromHex(hex) {
54  try {
55    return decodeURIComponent(String(hex).replace(/../g, '%$&'))
56  } catch {
57    return undefined
58  }
59}
60
61// A rejected $.process.run may quote its command, and the stdin may hold the
62// key, so the reason is put in words of our own
63async function run($, root, command, lines) {
64  try {
65    return await $.process.run(await credmanArgv($, root, command), { stdin: lines.map(hexOf).join('\n') + '\n' })
66  } catch (err) {
67    throw new Error(/ENOENT|not found/i.test(String((err && err.message) || err))
68      ? 'Windows PowerShell is missing: the Credential Manager backend needs it'
69      : 'credman.ps1 did not finish')
70  }
71}
72
73// credman.ps1's own one-line reason, which never holds a value
74const reason = (r) => String(r.stderr || '').replace(/^credman: /, '').split(/\r?\n/)[0].slice(0, 200)
75
76export async function saveToCredman($, root, service, account, value) {
77  const r = await run($, root, 'set', [service, account, value])
78  if (r.exitCode !== 0) throw new Error(reason(r) || `credman.ps1 exited ${r.exitCode}`)
79}
80
81// A read: what use_secret and the Bash pre-load ask credman.ps1 for
82export function credRead(service, account) {
83  return { kind: 'credman', service, ...(account ? { account } : {}) }
84}
85
86// Reads one item: { value, printed, account }, or { exitCode } when there is
87// no such item or it could not be read. `printed` is every way a command can
88// print it (bin/credman get prints the text as it is, and a command may print
89// one line of a value that has several); `value` is undefined when the bytes
90// are not text.
91export async function readCredman($, root, read) {
92  const r = await run($, root, 'read', [read.service, read.account ?? ''])
93  if (r.exitCode !== 0) return { exitCode: r.exitCode, why: reason(r) }
94  if (r.isStdoutTruncated) throw new Error("credman.ps1's answer was cut off")
95  const m = /^ok ((?:[0-9a-f]{2})*) (text|bytes) ((?:[0-9a-f]{2})*)\r?\n?$/.exec(String(r.stdout))
96  if (!m) throw new Error('credman.ps1 printed no value line')
97  const account = fromHex(m[1]) ?? ''
98  if (m[2] === 'bytes') return { value: undefined, printed: [], account }
99  const text = fromHex(m[3])
100  if (text === undefined) return { value: undefined, printed: [], account }
101  const lines = text.includes('\n') ? text.split(/\r?\n/).filter((l) => l.length >= MIN_HIDDEN) : []
102  return { value: text, printed: [...new Set([text, ...lines])].filter(Boolean), account }
103}
104
105// Whether Credential Manager holds the item a pointer names: true, false, or
106// undefined when that can't be told. No value is read out.
107export async function credExists($, root, { service, account }) {
108  try {
109    const r = await run($, root, 'exists', [service, account ?? ''])
110    return r.exitCode === 0 ? true : r.exitCode === CRED_NOT_FOUND ? false : undefined
111  } catch {
112    return undefined
113  }
114}
115
116// Every generic credential's names (target, user, comment), as list_key_names
117// filters them. credman.ps1 never reads a value out for this.
118export async function credmanItems($, root) {
119  let r
120  try {
121    r = await $.process.run([await powershellPath($), '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', credmanScript(root), 'list'])
122  } catch {
123    throw new Error('credman.ps1 did not finish')
124  }
125  if (r.exitCode !== 0) throw new Error(reason(r) || `credman.ps1 exited ${r.exitCode}`)
126  const items = []
127  for (const line of String(r.stdout).split(/\r?\n/)) {
128    const m = /^((?:[0-9a-f]{2})*) ((?:[0-9a-f]{2})*) ((?:[0-9a-f]{2})*)$/.exec(line)
129    if (!m || !m[1]) continue
130    items.push({ service: fromHex(m[1]) ?? '', account: fromHex(m[2]) ?? '', label: '', comment: fromHex(m[3]) ?? '' })
131  }
132  return { items: items.filter((i) => i.service), isCut: Boolean(r.isStdoutTruncated) }
133}
134
135// What Claude writes in a Bash command for a value it can't hold as a
136// placeholder: the shell reads the item as the command runs, so the value
137// never enters the command text
138export function credReadCommand(root, service, account) {
139  return `"$(${credmanCommand(root)} get -s ${shellWord(service)}${account ? ` -a ${shellWord(account)}` : ''})"`
140}
141
142// The command you run in your own terminal to add an item: it prompts for the
143// value with the typing hidden, so the key never lands in shell history, and
144// tags it for list_key_names
145export function credAddHint(root, service, account) {
146  return `powershell -NoProfile -ExecutionPolicy Bypass -File "${credmanScript(root)}" add -Service ${psWord(service)}${account ? ` -Account ${psWord(account)}` : ''}`
147}
148
149function shellWord(s) {
150  return /^[A-Za-z0-9_.,:@%+=\/-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`
151}
152
153// One argument as powershell.exe -File passes it on: bare when plain, else in
154// double quotes (a name holds no control character; badName sees to it)
155function psWord(s) {
156  return /^[A-Za-z0-9_.,:@%+=\/\\-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`
157}
158