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…

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.
claude plugin marketplace add ApisMellow/secret-box
claude plugin install secret-box@secret-box
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.
MIT. See LICENSE: copyright Ray Amjad and David Whitlock.
Full documentation is being written.
hooks/register.jsx 1118 lines1// 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}
1118hooks/redact.ts 436 lines1import 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}
436hooks/detect.ts 1650 lines1// 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\":`, `"token":`,
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 falsehooks/keychain.js 653 lines1// 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}
653hooks/credman.js 158 lines1// 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