SLOPSHOPPER

command-shelf

Command Shelf

guardcommandtoast
★ 1,095v0.1.1Apache-2.0updated 2026-10-23YeonwooSung/my-claude-code-mods/command-shelf
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-shelf
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /shelf ⎿ command-shelf: Command Shelf — successful foreground commands, latest 200; environment is not captured. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Command Shelf

Local Claude Code mod. Install: claude plugin install command-shelf@my-claude-code-mods --scope user, then reload plugins.

Successful-command history with explicit permission-aware replay.

Commands

/shelf help
/shelf [list | show <id> | pin <id> <label> | run <id> | remove <id>]
/shelf 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 automatic network calls. Explicit replay performs the selected command's normal side effects. Obvious secrets and terminal escapes are masked before storage/display; masking is best effort.

Limits and verification

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 command-shelf, npx tsc -p command-shelf. Engine typechecking needs local ignored declarations. Canonical runtime sources are in scripts/workflow/; run node scripts/sync-workflow.mjs after editing.

Example

/shelf list
/shelf pin 1 Auth regression tests
/shelf show 1
/shelf run 1

Up to 200 successful foreground commands. Commands containing recognized secrets or terminal escapes are excluded. Replay binds the original cwd and uses the Bash permission path; it never executes during listing. Other mods can observe an explicit replay. Environment values are not snapshotted. Shell cwd binding targets macOS/Linux.

Local preview before marketplace publication: claude --plugin-dir ./command-shelf from this checkout. Engine tests were run with Claude Code 2.1.302.

Hooks, programs and data flow

Observes successful foreground Bash calls and retains replayable command text/cwd. No direct filesystem, process or environment bridge is generated.

The command.run hook answers only /shelf 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.

This mod contains no process.run calls. The generated adapter omits the unused shared program-launch and environment-inspection code instead of merely leaving it dormant.

Only explicit /shelf run <id> invokes a tool itself: Bash, with the selected stored command preceded by a quoted cd -- <recorded-cwd>. This goes through normal host permissions. Viewing/pinning an entry does not run it. The selected command can run programs, read files or contact hosts according to its contents; its destinations/data are chosen by that command, not by a mod server. The adapter's other tool observations never initiate a replay.

There are no direct HTTP client calls, 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.

Directory review: session data and replay destination

The session.cwd read selects the local cwd/session store identity and provides the working-directory prefix for a recorded command. On explicit /shelf run <id>, the adapter sends only Bash and the selected stored command text with a quoted cd -- <recorded-cwd> prefix to the host's tool.call API. It does not include the rest of the shelf, session transcript, arbitrary store entries or environment-variable values in that call. The Bash result returns to the local caller; observers may see that tool call under normal host behavior.

This is a shell execution boundary: the selected command can read local files, launch other programs and send their data to hosts specified or resolved by that command and its configuration. There is no fixed host allowlist for explicit replay. Inspect the selected entry before running it; normal host tool permissions apply. The mod fetches no command from an HTTP service and has no direct HTTP client, telemetry endpoint or mod-chosen remote recipient. Listing, showing, pinning and observing commands never initiate a replay or upload. The tool.call observer forwards the original event once and records only eligible successful foreground Bash entries; it does not consume server responses as execution instructions.

Source 4 files
hooks/register.ts 78 lines
1import 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 = 'command-shelf'
6const command = 'shelf'
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.replay = async text => {
18    state.replaying = true
19    try { return await $.tool.call({ tool: 'Bash', command: text, description: 'Replay explicitly selected Command Shelf entry' }) }
20    finally { state.replaying = false }
21  }
22  return io
23}
24const identity = ($: EngineInterface) => Promise.all([$.session.cwd(), $.session.id()])
25const transact = async ($: EngineInterface, fn: (model: Model, io: Port) => Promise<string | void>, expected: string[], forceSave = false): Promise<string> => serial(async () => {
26  const [io, id] = await Promise.all([port($), $.session.id()])
27  if (io.cwd !== expected[0] || id !== expected[1]) return 'Session changed; operation not applied'
28  const key = `${name}:v1:${JSON.stringify([io.cwd, id])}`
29  const raw = await $.store.get(key)
30  const loaded = await identity($)
31  if (loaded[0] !== expected[0] || loaded[1] !== expected[1]) return 'Session changed; operation not applied'
32  const model = new Feature(raw)
33  const previous = JSON.stringify(safeData(model.data))
34  const result = await fn(model, io)
35  // No raw event payloads reach storage. Keep the serialized state bounded.
36  const serialized = JSON.stringify(safeData(model.data))
37  if (forceSave || serialized !== previous) {
38    if (serialized.length > 256 * 1024) throw new Error('state limit exceeded; not saved')
39    try { await $.store.set(key, JSON.parse(serialized)) }
40    catch { throw new Error('state not saved; retry after storage recovers') }
41  }
42  return reportText(result ?? '')
43})
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    try { await $.command.register({ name: command, description: `${name}: /${command} help` }) } catch { /* Startup still forwards. */ }
47    return next(e)
48  })
49  on('command.run', { command }, async ($, e) => {
50    try {
51      const expected = await identity($)
52      return { text: await transact($, async (model, io) => {
53        if ((e.args ?? '').trim() === 'reset') { model.data = new Feature().data; return `${name}: records reset` }
54        return model.command(e.args ?? '', io)
55    }, expected, (e.args ?? '').trim() === 'reset') } } catch (error) { return { text: `${name}: ${safeText(error instanceof Error ? error.message : error)}` } }
56  })
57  on('tool.call', async ($, e, next) => {
58    const ownReplay = state.replaying && (next.origin?.plugin === name || next.origin?.plugin.startsWith(name + '@'))
59    if (!observesTools || ownReplay) return next(e)
60    const expected = await identity($).catch(() => undefined)
61    let before: string | undefined
62    if (['verify-board', 'handoff-pack'].includes(name) && e.tool === 'Bash') {
63      try { before = await (await port($)).revision() } catch { /* Unknown evidence. */ }
64    }
65    // next runs outside observation catch: an observer never retries a tool or hides its exception.
66    const result = await next(e)
67    try {
68      const now = await Promise.all([$.session.cwd(), $.session.id()])
69      if (!expected || expected[0] !== now[0] || expected[1] !== now[1]) return result
70      await transact($, async (model, io) => {
71        const after = before !== undefined ? await io.revision() : undefined
72        await model.observe?.({ tool: e.tool, input: object(e), result, before, after }, io)
73      }, expected)
74    } catch { try { $.ui.toast(`${name}: observation not saved`) } catch { /* Noninteractive host. */ } }
75    return result
76  })
77}
78
hooks/core/runtime.ts 132 lines
1import { 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}
132
hooks/core/feature.ts 34 lines
1import { bounded, outcome, restore, safeText, type Observation, type Port } from './runtime.ts'
2type Entry = { id: number; command: string; cwd: string; label: string }
3const HELP = '/shelf [list | show <id> | pin <id> <label> | run <id> | remove <id> | reset] — only explicit run executes through Bash permissions.'
4export class Feature {
5  data: { nextId: number; commands: Entry[] }
6  constructor(value?: unknown) { this.data = restore(value, { nextId: 1, commands: [] as Entry[] }) }
7  async observe(e: Observation, io: Port): Promise<void> {
8    if (e.tool !== 'Bash' || outcome(e.result) !== 'pass') return
9    const command = e.input.command
10    if (typeof command !== 'string' || !command.trim() || command.length > 4000 || safeText(command) !== command || safeText(io.cwd) !== io.cwd) return
11    if (this.data.commands.some(c => c.command === command && c.cwd === io.cwd)) return
12    this.data.commands = bounded([...this.data.commands, { id: this.data.nextId++, command, cwd: io.cwd, label: '' }])
13  }
14  async command(args: string, io: Port): Promise<string> {
15    const [verb, idText, ...rest] = args.trim().split(/\s+/)
16    if (verb === 'help') return HELP
17    if (!verb || verb === 'list') return ['Command Shelf — successful foreground commands, latest 200; environment is not captured.', ...this.data.commands.map(c => `#${c.id} ${c.label ? c.label + ' — ' : ''}${c.command}\n  cwd: ${c.cwd}`)].join('\n')
18    const entry = this.data.commands.find(c => c.id === Number(idText))
19    if (!['show', 'pin', 'run', 'remove'].includes(verb)) return 'Usage: ' + HELP
20    if (!entry) return 'Command not found'
21    if (verb === 'pin') { const label = rest.join(' '); if (!label) return 'Usage: /shelf pin <id> <label>'; entry.label = safeText(label, 500) }
22    if (verb === 'remove') { this.data.commands = this.data.commands.filter(c => c !== entry); return 'Command removed' }
23    if (verb === 'run') {
24      if (entry.cwd !== io.cwd) return 'Refusing replay in a different project'
25      if (safeText(entry.command) !== entry.command) return 'Refusing replay of a masked command'
26      if (!io.replay) return 'Replay unavailable in this host'
27      const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
28      const result = await io.replay(`cd -- ${quote(entry.cwd)} && ${entry.command}`)
29      return outcome(result) === 'pass' ? `Command #${entry.id} completed` : `Command #${entry.id} not completed successfully; inspect Bash result`
30    }
31    return `#${entry.id} ${entry.label}\n${entry.command}\ncwd: ${entry.cwd}\nEnvironment values are not captured; review before /shelf run ${entry.id}`
32  }
33}
34
hooks/core/redact.ts 31 lines
1// 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