Env Doctor

Local Claude Code mod. Install: claude plugin install env-doctor@my-claude-code-mods --scope user, then reload plugins.
Project runtime requirements and environment-variable presence checks.
/env-doctor help
/env-doctor [check | require <ENV_NAME>]
/env-doctor reset
Reports are returned as command text, usable in terminal/Desktop/headless sessions. State is isolated by cwd and session ID in the plugin store; resuming the same identity restores it. Reset clears this mod's records only. Observations never block tools or execute commands twice. No network calls. Obvious secrets and terminal escapes are masked before storage/display; masking is best effort.
First release: see design for exact analysis limits. Git commands have a 10-second timeout. Lists cap at 200 entries and repository scans at 2,000 paths / 2 MiB; incomplete evidence is reported. Commands use explicit data entry; no model inference or automatic source editing. Actual engine tests cover command behavior and forwarding; live terminal/Desktop pixels have not been checked.
Development: npm test, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test env-doctor, npx tsc -p env-doctor. Engine typechecking needs local ignored declarations. Canonical runtime sources are in scripts/workflow/; run node scripts/sync-workflow.mjs after editing.
/env-doctor require DATABASE_URL
/env-doctor require API_TOKEN
/env-doctor check
Node/package-manager requirements come from package.json. Conservative ranges: exact, major/x, comparisons, full ^/~, and ||. Partial > and <= expressions, prereleases and other unsupported syntax return UNKNOWN. Dynamic env names are checked by a Node subprocess returning presence only; no variable values are returned or stored.
Local preview before marketplace publication: claude --plugin-dir ./env-doctor from this checkout. Engine tests were run with Claude Code 2.1.302.
Reads local package.json to compare Node/package-manager versions and checks user-selected variable names. Its tool hook only forwards.
The command.run hook answers only /env-doctor with local report/control text. It does not intercept unrelated slash commands. The tool.call hook forwards each original event once and returns its outcome unchanged; observer failures do not retry a tool. No tool.check, permission-mode or Remote Control override is installed.
The following complete argv templates describe every program launched with process.run. Each item is an argument array passed directly to the host, with no shell command parsing. <cwd>/<repo> are the current project or Git root, <base> is the validated selected ref, <manifest> is package.json or package-lock.json, and <path> is a selected project file. [--cached] denotes two fixed variants, with and without that argument. Every call has a 10-second timeout.
node --version
npm --version
pnpm --version
yarn --version
node -- <plugin-root>/hooks/env-presence.mjs <variable-name>
Version probes are dispatched to four separate calls with whole literal argv arrays; unsupported programs/options are refused. The presence check runs the bundled .mjs file, not node -e or downloaded code. It validates the variable name and uses Object.hasOwn(process.env, variableName) to test existence without retrieving a value. It prints only set or nothing. SET includes a variable declared with an empty value; MISSING means the name is absent. Required names and presence results remain local; no credential value is read, stored or shared. There is no sensitive userConfig value because no credential is required.
This mod makes no tool.call of its own. The common Bash-replay implementation is not included in its generated adapter; observing/forwarding an existing tool call does not create another call.
There are no HTTP requests, telemetry destinations or automatic uploads in this feature. Local read results support analysis/fingerprints and local report text; masked feature records go only to the plugin store under the current cwd/session identity. No project build, startup, settings or instruction files are edited. User/agent tool calls that are forwarded retain their independent behavior and destinations.
For review: String.raw/regular-expression literals in the redactor are string-pattern data, never a shell program. Template literals beginning # format record numbers. constructor names ordinary TypeScript class initialization. These are not credential reads or command execution. Runtime command access is limited to the concrete cases documented above.
Local package.json engine requirements are read; the process calls receive only fixed --version arguments or the bundled helper path plus a validated environment-variable name. No package.json contents or credential values are passed to these subprocesses.
The destinations of these calls are the local node, npm, pnpm, yarn executables and the bundled presence helper. Their stdout/stderr returns to this mod for version/presence checks and local report text; no result is passed to an HTTP client or replay tool. The helper returns presence only. Reports return to the local slash-command caller; persisted feature records go to this plugin’s local cwd/session-scoped store. This mod has no HTTP upload, remote recipient, server-provided command source or own tool replay. It forwards independently requested tool calls once; any network activity of those calls belongs to their original inputs and permissions. The program list above describes what the mod requests; installed executables and user/system configuration retain their own capabilities.
hooks/register.ts 91 lines1import type { EngineInterface, Register } from 'claude-code'
2import { object, revision, safeText, safeData, reportText, type Observation, type Port } from './core/runtime.ts'
3export interface Model { data: object; command(args: string, io: Port): Promise<string>; observe?(event: Observation, io: Port): Promise<void> }
4import { Feature } from './core/feature.ts'
5const name = 'env-doctor'
6const command = 'env-doctor'
7type AdapterState = { queue: Promise<unknown>; replaying: boolean }
8const state: AdapterState = { queue: Promise.resolve(), replaying: false }
9const observesTools = 'observe' in Feature.prototype
10const serial = <T>(fn: () => Promise<T>): Promise<T> => {
11 const work = state.queue.then(fn); state.queue = work.catch(() => {}); return work
12}
13const port = async ($: EngineInterface): Promise<Port> => {
14 const cwd = await $.session.cwd()
15 const unavailable = async (): Promise<never> => { throw new Error('Capability not used by this feature') }
16 const io: Port = { cwd, read: unavailable, run: unavailable, env: unavailable, revision: async () => undefined }
17 io.read = async path => await $.fs.read(path)
18 io.run = argv => {
19 if (argv.length !== 2 || argv[1] !== '--version') throw new Error('Only version probes are supported')
20 switch (argv[0]) {
21 case 'node': return $.process.run(['node', '--version'], { cwd, timeoutMs: 10_000 })
22 case 'npm': return $.process.run(['npm', '--version'], { cwd, timeoutMs: 10_000 })
23 case 'pnpm': return $.process.run(['pnpm', '--version'], { cwd, timeoutMs: 10_000 })
24 case 'yarn': return $.process.run(['yarn', '--version'], { cwd, timeoutMs: 10_000 })
25 default: throw new Error('Unsupported version program')
26 }
27 }
28 io.env = async variableName => {
29 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variableName)) throw new Error('invalid environment variable name')
30 const program = $.plugin.root + '/hooks/env-presence.mjs'
31 const r = await $.process.run(['node', '--', program, variableName], { cwd, timeoutMs: 10_000 })
32 if (r.exitCode !== 0) throw new Error('environment presence unavailable')
33 return r.stdout === 'set' ? 'set' : undefined
34 }
35 return io
36}
37const identity = ($: EngineInterface) => Promise.all([$.session.cwd(), $.session.id()])
38const transact = async ($: EngineInterface, fn: (model: Model, io: Port) => Promise<string | void>, expected: string[], forceSave = false): Promise<string> => serial(async () => {
39 const [io, id] = await Promise.all([port($), $.session.id()])
40 if (io.cwd !== expected[0] || id !== expected[1]) return 'Session changed; operation not applied'
41 const key = `${name}:v1:${JSON.stringify([io.cwd, id])}`
42 const raw = await $.store.get(key)
43 const loaded = await identity($)
44 if (loaded[0] !== expected[0] || loaded[1] !== expected[1]) return 'Session changed; operation not applied'
45 const model = new Feature(raw)
46 const previous = JSON.stringify(safeData(model.data))
47 const result = await fn(model, io)
48 // No raw event payloads reach storage. Keep the serialized state bounded.
49 const serialized = JSON.stringify(safeData(model.data))
50 if (forceSave || serialized !== previous) {
51 if (serialized.length > 256 * 1024) throw new Error('state limit exceeded; not saved')
52 try { await $.store.set(key, JSON.parse(serialized)) }
53 catch { throw new Error('state not saved; retry after storage recovers') }
54 }
55 return reportText(result ?? '')
56})
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 try { await $.command.register({ name: command, description: `${name}: /${command} help` }) } catch { /* Startup still forwards. */ }
60 return next(e)
61 })
62 on('command.run', { command }, async ($, e) => {
63 try {
64 const expected = await identity($)
65 return { text: await transact($, async (model, io) => {
66 if ((e.args ?? '').trim() === 'reset') { model.data = new Feature().data; return `${name}: records reset` }
67 return model.command(e.args ?? '', io)
68 }, expected, (e.args ?? '').trim() === 'reset') } } catch (error) { return { text: `${name}: ${safeText(error instanceof Error ? error.message : error)}` } }
69 })
70 on('tool.call', async ($, e, next) => {
71 const ownReplay = state.replaying && (next.origin?.plugin === name || next.origin?.plugin.startsWith(name + '@'))
72 if (!observesTools || ownReplay) return next(e)
73 const expected = await identity($).catch(() => undefined)
74 let before: string | undefined
75 if (['verify-board', 'handoff-pack'].includes(name) && e.tool === 'Bash') {
76 try { before = await (await port($)).revision() } catch { /* Unknown evidence. */ }
77 }
78 // next runs outside observation catch: an observer never retries a tool or hides its exception.
79 const result = await next(e)
80 try {
81 const now = await Promise.all([$.session.cwd(), $.session.id()])
82 if (!expected || expected[0] !== now[0] || expected[1] !== now[1]) return result
83 await transact($, async (model, io) => {
84 const after = before !== undefined ? await io.revision() : undefined
85 await model.observe?.({ tool: e.tool, input: object(e), result, before, after }, io)
86 }, expected)
87 } catch { try { $.ui.toast(`${name}: observation not saved`) } catch { /* Noninteractive host. */ } }
88 return result
89 })
90}
91hooks/core/runtime.ts 132 lines1import { redactSecrets } from './redact.ts'
2export type ProcessResult = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean; isStderrTruncated?: boolean }
3export type Port = {
4 cwd: string
5 read(path: string): Promise<string>
6 bytes?(path: string): Promise<string>
7 stat?(path: string): Promise<{ isLink: boolean }>
8 list?(path: string): Promise<string[]>
9 run(argv: readonly string[]): Promise<ProcessResult>
10 env(name: string): Promise<string | undefined>
11 revision(): Promise<string | undefined>
12 replay?(command: string): Promise<unknown>
13}
14export type Observation = { tool: string; input: Record<string, unknown>; result: unknown; before?: string; after?: string }
15export function object(v: unknown): Record<string, unknown> { return v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {} }
16export function safeText(v: unknown, limit = 4000): string {
17 const text = String(v ?? '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
18 // Some masking patterns backtrack on very long words. Omit an oversized line whole:
19 // cutting it first could expose an unterminated quoted secret.
20 const lines = text.split('\n')
21 // Dropping a credential's opening line could expose its quoted continuation.
22 if (lines.some(line => line.length > 8192 && /["'\\]|password|passwd|pwd|token|key|secret|authorization|credential|cookie/i.test(line))) return '[OMITTED: oversized sensitive value]'
23 return redactSecrets(lines.map(line => line.length > 8192 ? '[OMITTED: oversized line]' : line).join('\n')).slice(0, limit)
24}
25export function hash(text: string): string {
26 return [0, 0x9747b28c].map(seed => {
27 let h = (0x811c9dc5 ^ seed) >>> 0
28 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
29 return (h >>> 0).toString(16).padStart(8, '0')
30 }).join('')
31}
32export function relativePath(root: string, path: string): string | undefined {
33 const normalize = (p: string) => {
34 const parts: string[] = []
35 for (const s of p.replace(/\\/g, '/').split('/')) {
36 if (!s || s === '.') continue
37 if (s === '..') { if (!parts.length) return undefined; parts.pop() } else parts.push(s)
38 }
39 return parts.join('/')
40 }
41 const base = normalize(root); if (base === undefined) return undefined
42 const full = normalize(path.startsWith('/') ? path : `${root}/${path}`)
43 if (full === undefined) return undefined
44 if (base === '') return root.startsWith('/') ? full : undefined
45 if (!(full === base || full.startsWith(base + '/'))) return undefined
46 return full.slice(base.length).replace(/^\//, '')
47}
48export function changedPaths(status: string): string[] {
49 const entries = status.split('\0'); const paths: string[] = []
50 for (let i = 0; i < entries.length; i++) {
51 const line = entries[i]!; if (line.length < 4) continue
52 paths.push(line.slice(3))
53 if (/[RC]/.test(line.slice(0, 2)) && entries[i + 1]) paths.push(entries[++i]!)
54 }
55 return [...new Set(paths)]
56}
57export async function git(io: Port, args: string[]): Promise<string> {
58 const r = await io.run(['git', '-C', io.cwd, ...args])
59 if (r.exitCode !== 0 || r.isStdoutTruncated || r.isStderrTruncated) throw new Error('Git evidence unavailable or incomplete')
60 if (r.stdout.length > 2 * 1024 * 1024) throw new Error('Git evidence exceeds 2 MiB limit')
61 return r.stdout
62}
63export async function statusPaths(io: Port): Promise<string[]> { return changedPaths(await git(io, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])) }
64export async function revision(io: Port): Promise<string | undefined> {
65 try {
66 const root = (await git(io, ['rev-parse', '--show-toplevel'])).replace(/\n$/, '')
67 if (!root.startsWith('/')) return undefined
68 const repo = { ...io, cwd: root }
69 const head = await git(repo, ['rev-parse', '--verify', 'HEAD'])
70 const diff = await git(repo, ['diff', '--no-ext-diff', '--no-textconv', '--binary', '--ignore-submodules=none', 'HEAD', '--'])
71 const staged = await git(repo, ['diff', '--no-ext-diff', '--no-textconv', '--binary', '--ignore-submodules=none', '--cached', 'HEAD', '--'])
72 if (/^\+Subproject commit [a-f0-9]+-dirty$/m.test(diff) || /^\+Subproject commit [a-f0-9]+-dirty$/m.test(staged)) return undefined
73 const status = await git(repo, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'])
74 const paths = (await git(repo, ['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean).sort()
75 if (paths.length > 200) return undefined
76 const bodies: string[] = [head, diff, staged, status]; let size = diff.length + staged.length + status.length
77 for (const path of paths) {
78 if (relativePath(root, path) !== path || (io.stat && (await io.stat(`${root}/${path}`)).isLink)) return undefined
79 const body = await (io.bytes ?? io.read)(`${root}/${path}`); size += body.length
80 if (size > 2 * 1024 * 1024) return undefined
81 bodies.push(path, body)
82 }
83 return hash(JSON.stringify(bodies))
84 } catch { return undefined }
85}
86export function outcome(result: unknown): 'pass' | 'fail' | 'unknown' {
87 const a = object(result), r = object(a.result)
88 if (a.deny !== undefined) return 'unknown'
89 for (const v of [a, r]) {
90 if (['interrupted', 'cancelled', 'canceled', 'timedOut', 'timed_out', 'truncated', 'output_truncated', 'isStdoutTruncated', 'isStderrTruncated', 'backgroundedByUser', 'backgroundedByTurnAbort', 'backgroundedToDeliverMessage'].some(k => v[k] === true)) return 'unknown'
91 if (['task_id', 'taskId', 'backgroundTaskId', 'background_task_id', 'timedOutAfterMs', 'rawOutputPath', 'persistedOutputPath'].some(k => v[k] !== undefined && v[k] !== null && v[k] !== '')) return 'unknown'
92 }
93 if (a.isError === true) return 'fail'
94 const text = [a.text, r.stdout, r.stderr].filter((v): v is string => typeof v === 'string').join('\n').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
95 if (text.length > 65_536) return 'unknown'
96 if (/^# fail [1-9]\d*\s*$/m.test(text) || /^\s*Tests(?::|\s+)[^\n]*\b[1-9]\d* failed\b/m.test(text) || /^=+[^\n]*\b[1-9]\d* (?:failed|errors?)\b/m.test(text) || /^test result: FAILED\./m.test(text) || /^\s*--- FAIL: /m.test(text)) return 'fail'
97 const code = 'exitCode' in r ? r.exitCode : r.exit_code
98 if ('exitCode' in r || 'exit_code' in r) return typeof code === 'number' && Number.isInteger(code) ? code === 0 ? 'pass' : 'fail' : 'unknown'
99 // Builtin Bash exposes tool failure via isError and foreground completion via interrupted/streams, not exitCode.
100 return typeof r.stdout === 'string' && typeof r.stderr === 'string' && r.interrupted === false ? 'pass' : 'unknown'
101}
102export function isCheck(command: string): boolean {
103 if (/^(?:npx\s+)?vitest\b.*\slist(?:\s|$)/.test(command.trim())) return false
104 // Known introspection-only options do not run the check itself.
105 if (/(?:^|\s)(?:--help|-h|--version|-V|--collect-only|--co|--listTests|--list-tests|--showConfig|--show-config|--list|-list)(?:\s|=|$)/.test(command)) return false
106 // Conservative whole-command allowlist; compound shell programs are not proof of a check.
107 return /^(?:(?:npm|pnpm|yarn) (?:run )?(?:test(?::[\w-]+)?|typecheck|lint)|npx (?:tsc|vitest|jest|eslint)|(?:pytest|vitest|jest|tsc|eslint)|cargo (?:test|check|clippy)|go test)\b[^;&|<>\n]*$/.test(command.trim())
108}
109export function restore<T extends object>(value: unknown, defaults: T): T {
110 const v = object(value); const out = { ...defaults } as Record<string, unknown>
111 for (const [k, fallback] of Object.entries(defaults)) {
112 const item = v[k]
113 if (Array.isArray(fallback) ? Array.isArray(item) : typeof item === typeof fallback && item !== null) out[k] = item
114 }
115 return out as T
116}
117export const entries = (args: string): string[] => args.split('|').map(s => s.trim())
118export function bounded<T>(items: T[]): T[] { return items.slice(-200) }
119// Clean string leaves before JSON serialization, never regex-rewrite serialized JSON syntax.
120export function safeData(value: unknown): unknown {
121 if (typeof value === 'string') return safeText(value)
122 if (Array.isArray(value)) return value.map(safeData)
123 if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, safeData(v)]))
124 return value
125}
126
127export function reportText(value: unknown): string {
128 const limit = 32 * 1024, notice = '\nINCOMPLETE: report truncated; filter or inspect smaller groups.'
129 const text = safeText(value, limit + 1)
130 return text.length > limit || String(value ?? '').length > limit ? text.slice(0, limit - notice.length) + notice : text
131}
132hooks/core/feature.ts 84 lines1import { bounded, object, restore, safeText, type Port } from './runtime.ts'
2type Match = 'match' | 'mismatch' | 'unknown'
3export function satisfies(version: string, range: string): Match {
4 const v = version.trim().replace(/^v/, '')
5 if (!/^\d+\.\d+\.\d+$/.test(v)) return 'unknown'
6 const actual = v.split('.').map(Number)
7 const compare = (expected: number[]) => { for (let i = 0; i < 3; i++) { const diff = actual[i]! - (expected[i] ?? 0); if (diff) return Math.sign(diff) } return 0 }
8 const r = range.trim()
9 if (r.includes('||')) {
10 const results = r.split('||').map(part => satisfies(v, part))
11 return results.includes('match') ? 'match' : results.includes('unknown') ? 'unknown' : 'mismatch'
12 }
13 if (r === '*') return 'match'
14 if (/^\d+(?:\.(?:\d+|x|\*)){0,2}$/.test(r)) {
15 const parts = r.split('.')
16 const wildcard = parts.findIndex(p => p === 'x' || p === '*')
17 if (wildcard >= 0 && parts.slice(wildcard).some(p => p !== 'x' && p !== '*')) return 'unknown'
18 return parts.every((p, i) => p === 'x' || p === '*' || Number(p) === actual[i]) ? 'match' : 'mismatch'
19 }
20 const checks: boolean[] = []
21 for (const token of r.split(/\s+/)) {
22 const match = /^(>=|<=|>|<|=|\^|~)(\d+(?:\.\d+){0,2})$/.exec(token)
23 if (!match) return 'unknown'
24 const p = match[2]!.split('.').map(Number), cmp = compare(p)
25 if (p.length !== 3 && (match[1] === '>' || match[1] === '<=')) return 'unknown'
26 switch (match[1]) {
27 case '>=': checks.push(cmp >= 0); break
28 case '<=': checks.push(cmp <= 0); break
29 case '>': checks.push(cmp > 0); break
30 case '<': checks.push(cmp < 0); break
31 case '=': if (p.length !== 3) return 'unknown'; checks.push(cmp === 0); break
32 case '^': {
33 if (p.length !== 3) return 'unknown'
34 const upper = [...p], index = p.findIndex(n => n !== 0)
35 const bump = index < 0 ? 2 : index
36 upper[bump] = upper[bump]! + 1
37 for (let i = bump + 1; i < 3; i++) upper[i] = 0
38 checks.push(cmp >= 0 && compare(upper) < 0); break
39 }
40 case '~': if (p.length !== 3) return 'unknown'; checks.push(cmp >= 0 && compare([p[0]!, p[1]! + 1, 0]) < 0); break
41 }
42 }
43 return checks.length && checks.every(Boolean) ? 'match' : 'mismatch'
44}
45export class Feature {
46 data: { requiredEnv: string[] }
47 constructor(value?: unknown) { this.data = restore(value, { requiredEnv: [] as string[] }) }
48 async command(args: string, io: Port): Promise<string> {
49 const [verb, ...rest] = args.trim().split(/\s+/)
50 if (verb === 'help') return '/env-doctor [check | require <ENV_NAME> | reset] — Node/package-manager versions and variable presence only. Ranges: exact/major/x, comparisons, full ^/~, ||; other syntax UNKNOWN.'
51 if (verb === 'require') {
52 const name = rest.join(' ')
53 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return 'Invalid environment variable name'
54 this.data.requiredEnv = bounded([...new Set([...this.data.requiredEnv, name])])
55 } else if (verb && verb !== 'check') return 'Usage: /env-doctor [check | require <ENV_NAME> | reset]'
56 let manifest: Record<string, unknown>
57 try {
58 const parsed: unknown = JSON.parse(await io.read(`${io.cwd}/package.json`))
59 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('JSON object required')
60 manifest = object(parsed)
61 }
62 catch { return 'Env Doctor: package.json manifest unavailable or malformed' }
63 const engines = object(manifest.engines), lines = ['Env Doctor — presence only; no environment values retained']
64 const inspect = async (binary: string, range: string) => {
65 try {
66 const r = await io.run([binary, '--version'])
67 const version = r.stdout.trim()
68 const match = r.exitCode !== 0 || r.isStdoutTruncated || !range ? 'unknown' : satisfies(version, range)
69 lines.push(`${binary}: ${safeText(version || '(unavailable)', 100)} — ${match.toUpperCase()} ${safeText(range || '(no requirement)')}`)
70 } catch { lines.push(`${binary}: UNKNOWN (binary unavailable)`) }
71 }
72 await inspect('node', typeof engines.node === 'string' ? engines.node : '')
73 const declared = typeof manifest.packageManager === 'string' ? /^(npm|pnpm|yarn)@([^+]+)(?:\+.*)?$/.exec(manifest.packageManager) : undefined
74 if (declared) await inspect(declared[1]!, declared[2]!)
75 else for (const binary of ['npm', 'pnpm', 'yarn']) if (typeof engines[binary] === 'string') await inspect(binary, engines[binary] as string)
76 if (manifest.packageManager && !declared) lines.push('Package manager: UNKNOWN unsupported declaration')
77 for (const name of this.data.requiredEnv) {
78 try { lines.push(`${name}: ${await io.env(name) ? 'SET' : 'MISSING'}`) }
79 catch { lines.push(`${name}: UNKNOWN (presence unavailable)`) }
80 }
81 return lines.join('\n')
82 }
83}
84hooks/core/redact.ts 31 lines1// Canonical source; run node scripts/sync-redactors.mjs after editing.
2// Best-effort removal of obvious secrets from command lines before they are exported.
3const VALUE = String.raw`("(?:\\[\s\S]|[^"\\])*"|'[^']*'|(?:\\[\s\S]|[^\s'"\\])+)`
4const HEADER = String.raw`(?:authorization|[a-z0-9][a-z0-9-]*-(?:key|token|secret))`
5// Only match user:password arguments, leaving username-only options (e.g. mysql -u) alone.
6const CREDENTIAL = String.raw`("(?:\\[\s\S]|[^":\\])*:(?:\\[\s\S]|[^"\\])*"|'[^':]*:[^']*'|[^\s'":]*:[^\s'"]*)`
7const RULES: [RegExp, string][] = [
8 // Shell-quoted headers must be handled before key/value rules: an escaped quote is
9 // part of the secret, while the opposite quote is ordinary data inside this shell word.
10 [new RegExp(String.raw`("` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^"\\])*"`, 'gi'), '$1[REDACTED]"'],
11 [new RegExp(String.raw`('` + HEADER + String.raw`\s*:\s*)[^']*'`, 'gi'), "$1[REDACTED]'"],
12 [new RegExp(String.raw`(\b` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^'"\r\n\\])+`, 'gi'), '$1[REDACTED]'],
13 [new RegExp(String.raw`((?:["']?)(?:password|passwd|pwd|token|(?:access|refresh|id|session|auth)[_-]?token|(?:api|access|secret|private)[_-]?key|client[_-]?secret|secret|authorization|credentials?|cookies?)(?:["']?)\s*:\s*)` + VALUE, 'gi'), '$1[REDACTED]'],
14 [/(\bbearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]'],
15 [new RegExp(String.raw`(--(?:password|token|api-key|secret)(?:=|\s+))` + VALUE, 'gi'), '$1[REDACTED]'],
16 [/([a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]*:)[^\s/@'"]*@/gi, '$1[REDACTED]@'],
17 [new RegExp(String.raw`(\s(?:--user(?:=|\s+)|-u\s*))` + CREDENTIAL, 'g'), '$1[REDACTED]'],
18 [/(\bmysql\w*\b[^\n;&|]*?\s-p)(?=\S)("[^"]*"|'[^']*'|\S+)/g, '$1[REDACTED]'],
19 [new RegExp(String.raw`\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)=` + VALUE, 'gi'), '$1=[REDACTED]'],
20]
21
22export function redactSecrets(text: string): string {
23 return RULES.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
24}
25
26// Match complete key names, including common camelCase/underscore/hyphen spellings.
27// Do not match ordinary names such as token_count or keyboard.
28export function isSecretKey(key: string): boolean {
29 return /^(?:password|passwd|pwd|authorization|proxyauthorization|apikey|accesskey|secretkey|privatekey|clientsecret|secret|token|accesstoken|refreshtoken|idtoken|sessiontoken|authtoken|credential|credentials|cookie|setcookie)$/i.test(key.replace(/[_\s-]/g, '')) || /^[a-z0-9][a-z0-9-]*-(?:key|token|secret)$/i.test(key)
30}
31