The raw record of a session: every event that crosses Claude Code, whoever raised it (you, the model, the engine, settings hooks, plugins), with its full…

The raw record of a session: every event that crosses Claude Code, in the order it happened, with its full payload written to disk. It names who raised each one: you, the model, the engine, settings hooks or a plugin. Use it when you need to know exactly what was sent and returned, or to see what a plugin does behind your back.
agent-log is the interpreted view of the same session: turns, timings and who decided each call. The two work as a pair, agent-log to find the moment and event-log to read what actually crossed.
enter row before its hook chain runs and an exit row after, so nested events land between the two and the order on disk is the order things happened. An exit row carries the result or the error and how long the chain took.Authorization headers) is masked as *** on a best-effort basis.ui.*, apart from ui.fault), typing and autocomplete, clock ticks, and plugins' read-only queries. A tally row per flush says how many there were./plugin install event-log --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
The pane stays closed until /eventlog opens it, and shows one line per event, an enter and its exit merged when nothing came between them. Pressing a line opens its full rows, and b goes back. /eventlog shows or hides the pane, and /eventlog you|claude|engine|hook|plugin keeps only the events that one source raised (all clears the filter). No /eventlog enters the conversation: its line is dropped and its reply is a toast, so the model reads neither.
To check a session's log on disk:
node plugins/event-log/check-log.mjs ~/.claude/event-log/<session_id>
It prints a PASS or FAIL line per check (sequence numbers without gaps, every exit with its enter), then a count of rows per event, and exits 1 on any FAIL.
~/.claude/event-log/<session_id>/0001.jsonl, 0002.jsonl, and so on, one JSON row per line, in chunks of about 1 MiB, flushed every 2 seconds and at session end. Past the maxMB option (50 MB per session by default) it stops writing for good and the pane says so; the rows stay in the pane only. Old sessions are not pruned: delete their folders when you no longer need them.claude --plugin-dir plugins/event-log # loads it from source
tsc -p plugins # type-checks every plugin against plugins/types/
claude plugin test plugins/event-log # tests
hooks/scrub.ts is a copy of plugins/shared/scrub.ts: edit that file and run plugins/shared/sync.sh.
hooks/event-log.tsx 337 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { scrub } from './scrub'
3
4// One flat, complete, chronological record of a session: every event that crosses Claude Code, whoever raised it, as an
5// enter row before the chain runs and an exit row after. Nested events land between the two, so seq order is the order
6// things happened, with no folds. Full payloads go to disk; the pane shows a peek per row.
7// Sections, in order (search `// ===`): TEXT, ROWS, STORAGE, HOOKS, PANE
8
9// === TEXT ===
10const CAP = 16 * 1024 // chars kept per string in a payload
11const PEEK = 160
12// scrub: secret-shaped text as ***, from hooks/scrub.ts (a copy of plugins/shared/scrub.ts, as agent-log's is); re-exported for the tests
13export { scrub }
14
15// JSON text of any event value: never throws. Cycles are tracked by ancestor stack, so a value shared by two fields is
16// written twice, not called a cycle
17export function ser(x: unknown): string {
18 const stack: object[] = []
19 try {
20 return JSON.stringify(x, function (this: unknown, _k, v) {
21 if (typeof v === 'string') { const s = scrub(v); return s.length > CAP ? `${s.slice(0, CAP)}…(+${s.length - CAP} chars)` : s }
22 if (typeof v === 'function') return '[fn]'
23 if (typeof v === 'bigint') return String(v)
24 if (v && typeof v === 'object') {
25 const Sig = (globalThis as any).AbortSignal // not in lib es2023; the plugin runtime has it
26 if (Sig && v instanceof Sig) return '[signal]'
27 if (v instanceof Uint8Array) return `[bytes ${v.length}]`
28 if (v instanceof Error) return { name: v.name, message: scrub(v.message) }
29 while (stack.length && stack[stack.length - 1] !== this) stack.pop()
30 if (stack.includes(v)) return '[cycle]'
31 stack.push(v)
32 }
33 return v
34 }) ?? 'null'
35 } catch (err) { return JSON.stringify(`[unserializable: ${err}]`) }
36}
37
38// the fields that say most about an event, in the order a peek prefers them
39const KEYS = ['prompt', 'text', 'command', 'file_path', 'path', 'pattern', 'url', 'query', 'answer', 'message', 'content', 'args', 'description', 'error', 'deny', 'reason', 'name', 'title', 'id', 'value']
40function find(x: unknown, d: number): string | undefined {
41 if (typeof x === 'string') return x.trim() || undefined
42 if (!x || typeof x !== 'object' || d > 3) return undefined
43 if (Array.isArray(x)) { for (const v of x.slice(0, 20)) { const s = find(v, d + 1); if (s) return s } return undefined }
44 for (const k of KEYS) if (k in x) { const s = find((x as any)[k], d + 1); if (s) return s }
45 return undefined
46}
47// One line of the most telling string field; '' when there is none, since the row shows the event's name beside it
48export function peekOf(x: unknown): string {
49 const tool = x && typeof x === 'object' && typeof (x as any).tool === 'string' ? (x as any).tool : ''
50 const s = scrub(find(x, 0) ?? '').replace(/\s+/g, ' ').trim()
51 return [tool, s].filter(Boolean).join(' ').slice(0, PEEK)
52}
53
54// === ROWS ===
55export type Origin = { readonly plugin: string; readonly tier: string }
56// data: the payload as JSON text (e on enter, the result or error on exit, the counts on tally), spliced into the line raw
57// enter: on an exit row, the seq of its enter row. err: the chain threw
58export type Row = { seq: number; t: number; ph: 'enter' | 'exit' | 'tally'; ev: string; origin?: Origin; agent?: string; who: string; peek: string; data?: string; enter?: number; ms?: number; err?: true }
59export const WHO = ['you', 'claude', 'engine', 'hook', 'plugin'] as const
60// prompt origins that are the person: typed (composer), `claude -p` (sdk), remote control (bridge); ui.* events use person
61const PERSON = new Set(['person', 'composer', 'sdk', 'bridge'])
62
63export function whoOf(ev: string, origin: Origin, e: any, ph: 'enter' | 'exit' = 'enter'): string {
64 if (ev.startsWith('classic.')) return 'hook' // the settings hooks' events
65 if (origin.plugin !== 'engine') return `plugin:${origin.plugin}` // its $ call raised it
66 if (PERSON.has(e?.origin?.kind)) return 'you'
67 const m = e?.message
68 if ((ev === 'turn.step' && ph === 'exit') || ev === 'tool.call' || (ev === 'session.append' && (m?.type === 'assistant' || m?.role === 'assistant'))) return 'claude'
69 return 'engine'
70}
71
72// counted, not stored: a tally row per flush says how many
73// ui.*: the screen, not the session. ui.fault stays a row: a plugin's pane broke
74const NOISE = new Set(['prompt.edit', 'prompt.autocomplete', 'prompt.suggest', 'session.measure'])
75export const isNoise = (ev: string) => NOISE.has(ev) || (ev.startsWith('ui.') && ev !== 'ui.fault') || ev.startsWith('clock.') || ev.endsWith('.describe')
76
77// Read-only queries never redraw the pane: other plugins make them from their own render hooks (skins' state.get) and
78// polls (agent-log's ui.panes, agent.list), so redrawing for them redraws every 2 s while idle, forever. A plugin's are
79// counted, not stored (enter): they were 60-70% of a log's bytes, skins re-reading the same state ~10×/s while idle
80const QUERY = new Set(['ui.panes', 'session.id', 'session.cwd', 'session.root', 'session.repo', 'session.version', 'session.model', 'session.usage', 'session.surfaces'])
81export const isQuery = (ev: string) => QUERY.has(ev) || /\.(get|list|keys|read|exists|stat)$/.test(ev)
82
83const PANE = 'event-log'
84const ROW_CAP = 256 * 1024 // one row's data; past it the line keeps a head, so one huge event cannot fill a chunk
85let n = 0 // seq of the last row made, before base is added (flush)
86let pending: Row[] = [] // rows made since the last flush
87const counts = new Map<string, number>()
88
89export function enter(ev: string, origin: Origin, e: any): Row | undefined {
90 // our own $ calls: the engine skips only the calling hook's frame, so the timer's fs.write would reach this hook and
91 // log itself every flush, forever
92 if (origin.plugin === PANE) return undefined
93 const plugin = origin.plugin !== 'engine'
94 if (isNoise(ev) || (plugin && isQuery(ev))) {
95 if (e?.requestId === PANE) return undefined // this pane's own redraws and scrolls: tallying them would redraw it again, forever
96 const k = plugin ? `${ev} (${origin.plugin})` : ev // per plugin, so a poller still stands out in the tally
97 counts.set(k, (counts.get(k) ?? 0) + 1)
98 return undefined
99 }
100 const agent = typeof e?.agentId === 'string' ? e.agentId : undefined
101 const r: Row = { seq: ++n, t: Date.now(), ph: 'enter', ev, origin, ...(agent ? { agent } : {}), who: whoOf(ev, origin, e), peek: peekOf(e), data: ser(e) }
102 pending.push(r)
103 return r
104}
105
106export function exit(s: Row, e: any, r: unknown, err?: unknown): Row {
107 const x: Row = { seq: ++n, t: Date.now(), ph: 'exit', ev: s.ev, origin: s.origin, ...(s.agent ? { agent: s.agent } : {}), who: whoOf(s.ev, s.origin!, e, 'exit'), peek: peekOf(err ?? r), data: ser(err !== undefined ? { error: err } : r), enter: s.seq, ms: Date.now() - s.t, ...(err !== undefined ? { err: true as const } : {}) }
108 pending.push(x)
109 return x
110}
111
112export function tally(): Row | undefined {
113 if (!counts.size) return undefined
114 const c = Object.fromEntries([...counts].sort((a, b) => b[1] - a[1]))
115 counts.clear()
116 // the peek is the total only: the breakdown is the data, which the inspector shows
117 return { seq: ++n, t: Date.now(), ph: 'tally', ev: 'tally', who: 'tally', peek: `${Object.values(c).reduce((a, b) => a + b, 0)} counted`, data: JSON.stringify(c) }
118}
119
120// one JSONL line; data is already JSON text, spliced in rather than parsed and written again
121export function lineOf(r: Row): string {
122 const { data, ...rest } = r
123 let d = data ?? 'null'
124 if (d.length > ROW_CAP) d = JSON.stringify({ truncated: d.length, head: d.slice(0, ROW_CAP) })
125 return `${JSON.stringify(rest).slice(0, -1)},"data":${d}}\n`
126}
127
128// === STORAGE ===
129// ~/.claude/event-log/<sessionId>/0001.jsonl, 0002.jsonl, … $.fs.write writes whole files and caps them at 4 MiB, so the
130// current chunk's text lives here and is rewritten on each flush. ROTATE counts UTF-16 units: at most 3 UTF-8 bytes each,
131// so a chunk stays under 3 MiB. These writes are $ calls other plugins' hooks see (redact, context-mode); ours skip them
132const ROTATE = 1024 * 1024
133const KEEP = 2000 // rows the pane holds
134export type Chunks = { n: number; buf: string }
135const chunks: Chunks = { n: 1, buf: '' }
136let dir: string | undefined // undefined: no HOME, rows stay in memory
137let base = 0 // the last seq on disk when this module loaded; a hot reload continues the count from there
138let tail: Row[] = [] // the newest flushed rows, for the pane
139let ready: Promise<void> | undefined
140
141// appends lines to the current chunk; returns the chunks filled on the way, each to be written one last time
142export function pack(c: Chunks, lines: string[], limit = ROTATE): { n: number; text: string }[] {
143 const done: { n: number; text: string }[] = []
144 for (const l of lines) {
145 if (c.buf && c.buf.length + l.length > limit) { done.push({ n: c.n, text: c.buf }); c.n++; c.buf = '' }
146 c.buf += l
147 }
148 return done
149}
150
151const file = (k: number) => `${dir}/${String(k).padStart(4, '0')}.jsonl`
152const parse = (text: string): Row[] => text.split('\n').flatMap(l => { try { const o = JSON.parse(l); return [{ ...o, data: JSON.stringify(o.data) }] } catch { return [] } })
153
154// A hot reload wipes module memory: the last chunk on disk gives back its text, the pane's rows and seq. Rows made in the
155// ≤2 s since the last flush before the reload are lost
156async function restore($: EngineInterface): Promise<void> {
157 const home = await $.env.get('HOME').catch(() => undefined)
158 if (!home) return
159 dir = `${home}/.claude/event-log/${await $.session.id()}`
160 try {
161 const last = (await $.fs.list(dir)).map(f => f.name).filter(x => /^\d{4}\.jsonl$/.test(x)).sort().at(-1)
162 if (!last) return
163 const text = await $.fs.read(`${dir}/${last}`)
164 if (typeof text !== 'string') return
165 const rows = parse(text)
166 chunks.n = Number(last.slice(0, 4)); chunks.buf = text
167 tail = rows.slice(-KEEP); base = rows.at(-1)?.seq ?? 0
168 } catch {} // no folder yet: a new session
169}
170
171let flushing: Promise<void> | undefined
172let shown = false // the pane is open: only then does a flush redraw it
173async function flushOnce($: EngineInterface): Promise<void> {
174 await (ready ??= restore($).catch(() => { ready = undefined })) // failed (session.id rejected): the next flush tries again
175 const t = tally()
176 if (t) pending.push(t)
177 if (!pending.length) return
178 // copies, not in place: capture still holds a pending enter row across its await, and its exit's enter must stay local
179 const rows = pending.map(r => ({ ...r, seq: r.seq + base, ...(r.enter !== undefined ? { enter: r.enter + base } : {}) })); pending = []
180 tail = [...tail, ...rows].slice(-KEEP)
181 const done = pack(chunks, rows.map(lineOf))
182 if (dir && !full) {
183 // the cap: a plugin looping on $ calls (skins' state.get on every redraw) wrote ~250 KB/s; past maxMB the session
184 // stops writing for good, its rows only in the pane. Every chunk before the current one holds ≤ROTATE chars
185 for (const c of done) if (c.n * ROTATE <= cap) await $.fs.write(file(c.n), c.text)
186 if ((chunks.n - 1) * ROTATE + chunks.buf.length > cap) full = true
187 else await $.fs.write(file(chunks.n), chunks.buf)
188 }
189 // Never from inside the * hook, never for a tally alone (a redraw raises ui.render, which tallies), and only for what the
190 // engine raised: a plugin's $ calls (skins' state.set from its render hook, agent-log's poll) never redraw, else each
191 // redraw that makes them makes the next. They show on the next redraw
192 if (shown && rows.some(r => r.ph !== 'tally' && r.origin?.plugin === 'engine' && !isQuery(r.ev))) $.ui.invalidate('ui.render')
193}
194let full = false // the cap was reached: nothing more is written this session
195let cap = 50 * 1024 * 1024 // chars per session (≈ bytes for ASCII JSON); the maxMB option
196export const flush = ($: EngineInterface) => flushing ??= flushOnce($).finally(() => { flushing = undefined })
197
198// === HOOKS ===
199let timer: Timer | undefined
200let filter: string | undefined // a WHO entry; undefined shows every row
201let back = 0 // lines between the window's end and the newest line; 0 follows
202let maxBack = 0
203let inspect: Row[] | undefined
204
205async function openPane($: EngineInterface, focus: boolean) {
206 await $.ui.open({ id: PANE, title: 'Event log', ...(focus ? { focus: true as const } : {}) })
207 shown = true
208}
209
210// The * hook: an enter row, the chain, an exit row. It never changes e or the result, and a failure of its own never
211// reaches the chain. Declared at the top level: the loader only takes a hook it can see declared there
212async function capture($: EngineInterface, e: unknown, next: any) {
213 if (next.is('engine.create', e)) return next(e) // $ is the empty table there
214 let s: Row | undefined
215 try { s = enter(next.event, next.origin, e) } catch {}
216 if (!s) return next(e)
217 try {
218 const r = await next(e)
219 try { exit(s, e, r) } catch {}
220 return r
221 } catch (err) {
222 try { exit(s, e, undefined, err) } catch {}
223 throw err
224 }
225}
226
227export const register: Register = (on, options) => {
228 cap = Math.max(0.001, Number(options.maxMB) || 50) * 1024 * 1024
229 on('*', capture)
230 // `*` skips telemetry; the anthropic stream is for built-in plugins, so this sees only an OpenTelemetry collector's
231 on('telemetry.*', { to: 'collector' }, capture as any)
232
233 on('session.start', async ($, e, next) => {
234 try { await $.command.register({ name: 'eventlog', description: 'Toggle the event log pane; "you|claude|engine|hook|plugin" filters it by who raised the event, "all" clears the filter.', argumentHint: '[you|claude|engine|hook|plugin|all]', immediate: true }) } catch {} // throws on hot reload: no session bound yet
235 timer ??= $.clock.every(2000, () => { flush($).catch(() => {}) })
236 // the pane never opens on its own: /eventlog opens it
237 await (ready ??= restore($).catch(() => { ready = undefined }))
238 return next(e)
239 })
240
241 on('session.end', async ($, e, next) => {
242 timer?.cancel(); timer = undefined
243 try { await flush($); await flush($) } catch {} // the second takes rows made while an in-flight flush was writing
244 return next(e)
245 })
246
247 on('ui.close', async ($, e, next) => {
248 if (e.id === PANE) shown = false
249 return next(e)
250 })
251
252 // /eventlog stays out of the conversation: its reply is a toast, and prompt.submit drops its line
253 on('command.run', { command: 'eventlog' }, async ($, e) => {
254 const a = e.args.trim()
255 const r = await (async () => {
256 if (a) {
257 if (a !== 'all' && !(WHO as readonly string[]).includes(a)) return { text: `usage: /eventlog [${WHO.join('|')}|all]` } // a typo must not toggle the pane shut
258 filter = a === 'all' ? undefined : a; back = 0; inspect = undefined
259 if (shown) $.ui.invalidate('ui.render') // a closed pane stays closed: the filter waits for the next /eventlog
260 return { text: `event log: ${a}${shown ? '' : ' (pane hidden; /eventlog shows it)'}` }
261 }
262 const up = (await $.ui.panes()).some(p => p.id === PANE)
263 if (up) { await $.ui.close({ id: PANE }); shown = false } else await openPane($, true)
264 return { text: up ? 'event log hidden' : 'event log shown' }
265 })()
266 try { $.ui.toast(r.text) } catch {}
267 return {}
268 })
269 on('prompt.submit', async ($, e, next) => e.command?.name === 'eventlog' ? { drop: 'eventlog: kept out of the conversation' } : next(e))
270
271 // the window is the log's own, so the header stays put; the inspector's wrapped text the engine scrolls
272 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
273 if (inspect || e.origin.kind !== 'person') return next(e)
274 back = Math.max(0, Math.min(maxBack, back - e.by))
275 $.ui.invalidate('ui.render')
276 return {}
277 })
278
279 // === PANE ===
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 const { Box, Text, Button } = $.ui.resolve(e)
282 const cols = e.props.bodyColumns ?? 80
283 const bodyRows = e.props.scroll?.bodyRows ?? 24
284 if (inspect) {
285 const rs = inspect
286 const text = rs.map(r => { let d: unknown = r.data; try { d = JSON.parse(r.data ?? 'null') } catch {} return JSON.stringify({ ...r, data: d }, null, 2) }).join('\n\n')
287 return (
288 <Box flexDirection="column" width={cols}>
289 <Button key="back" label="← back (b)" hotkey="b" onPress={() => { inspect = undefined; $.ui.invalidate('ui.render'); $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {}) }} />
290 <Text key="data" wrap="wrap">{text.length > 64 * 1024 ? `${text.slice(0, 64 * 1024)}\n…(+${text.length - 64 * 1024} chars, full row on disk)` : text}</Text>
291 </Box>
292 )
293 }
294 const lines = linesOf(tail, filter)
295 const room = Math.max(1, bodyRows - 1)
296 maxBack = Math.max(0, lines.length - room)
297 back = Math.min(back, maxBack)
298 const shownLines = lines.slice(Math.max(0, lines.length - room - back), lines.length - back)
299 const head = `${filter ?? 'all'} · ${lines.length} rows${back ? ` · ${back} newer below` : ''} · ${!dir ? 'not on disk' : full ? `log full at ${+(cap / 1048576).toFixed(3)} MB, no longer writing: ${dir}` : dir}`
300 return (
301 <Box flexDirection="column">
302 <Text key="head" dimColor wrap="truncate-end">{head}</Text>
303 {!lines.length && <Text key="empty" dimColor>No events yet.</Text>}
304 {shownLines.map(l => <Button key={l.key} label={l.text.length > cols - 1 ? `${l.text.slice(0, Math.max(1, cols - 2))}…` : l.text} plain onPress={() => { inspect = l.rows; $.ui.invalidate('ui.render'); $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {}) }} />)}
305 </Box>
306 )
307 })
308}
309
310const pad = (x: number, w = 2) => String(x).padStart(w, '0')
311const hms = (t: number) => { const d = new Date(t); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` }
312const done = (x: Row) => `${x.ms}ms ${x.err ? 'threw' : 'ok'}`
313const tag = (who: string) => who.replace(/^plugin:/, '').padEnd(10).slice(0, 10)
314
315// One line per event. An exit merges into its enter when only rows of the same event came between: a concurrent batch,
316// the engine raising all of a burst's enters (113 prompt.attachment on a reload) before any exit. An exit with other
317// events nested before it points back with ↳
318export type Line = { key: string; rows: Row[]; text: string }
319export function linesOf(rows: Row[], filter?: string): Line[] {
320 const rs = filter ? rows.filter(r => filter === 'plugin' ? r.who.startsWith('plugin:') : r.who === filter) : rows
321 const out: Line[] = []
322 const merged = new Set<number>() // seqs of exits already shown on their enter's line
323 for (let i = 0; i < rs.length; i++) {
324 const a = rs[i]!
325 if (merged.has(a.seq)) continue
326 let j = i + 1
327 if (a.ph === 'enter') while (j < rs.length && rs[j]!.ev === a.ev && rs[j]!.enter !== a.seq) j++
328 const b = rs[j]
329 if (a.ph === 'enter' && b?.ph === 'exit' && b.enter === a.seq) {
330 out.push({ key: `r${a.seq}`, rows: [a, b], text: `${hms(a.t)} ${tag(b.who)} ${a.ev} ${a.peek || b.peek} · ${done(b)}` })
331 merged.add(b.seq)
332 } else if (a.ph === 'exit') out.push({ key: `r${a.seq}`, rows: [a], text: `${hms(a.t)} ${tag(a.who)} ↳ ${a.ev} ${done(a)} ${a.peek}` })
333 else out.push({ key: `r${a.seq}`, rows: [a], text: `${hms(a.t)} ${tag(a.who)} ${a.ev} ${a.peek}` })
334 }
335 return out
336}
337hooks/scrub.ts 29 lines1// Copied from plugins/shared/scrub.ts by plugins/shared/sync.sh: edit that file, then run the script.
2// The secret scrub agent-log and event-log share: one source, imported by both. A secret's value as ***, the text around it
3// kept. Best-effort, not a guarantee (ponytail). The rules are redact's (plugins/redact/hooks/redact.tsx RULES, minus its email
4// rule: paths and git output stay readable), plus three shapes it lacks: an Authorization header's value, curl's
5// `-u user:pass` and mysql's `-p<pass>`. redact keeps its own copy (with the email rule); keep the two in step. Repeats that
6// can fail are bounded, as redact's speed test pins
7const NAME = String.raw`\w{0,64}(?:token|secret|password|passwd|api[_-]?key|private[_-]?key|access[_-]?key|credential)\w{0,64}`
8const STOP = String.raw`\s'"\`,;&<>()[\]{}⟦⟧`
9const VAL = String.raw`[^${STOP}]+`
10const END = String.raw`(?![^${STOP}])`
11// an unquoted value that reads as code stays: a `$` reference, a call or index, a keyword or literal, a dotted path, the name itself
12const CODE = String.raw`(?!-?\$|[\w.]+[(\[]|(?:await|new|string|number|boolean|bool|str|int|bytes|any|unknown|undefined|none|nil|true|false|null|\d+|[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+|\k<name>|-)${END})`
13// a rule masks its v (or v2) group and keeps pre, q and post; one with no groups masks its whole match
14const SCRUB: RegExp[] = [
15 /-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]{0,8192}?-----END [A-Z ]*PRIVATE KEY-----|(?:(?:\r?\n|\\n)[ \t]*(?:\d+\t)?[A-Za-z0-9+/=]+(?=[\r\n\\]|$))+)/g,
16 /\b(?:sk-[\w-]{20,}|gh[pousr]_\w{36,}|github_pat_\w{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abprs]-[\w-]{10,}|AIza[\w-]{35}|[sr]k_(?:live|test)_[0-9A-Za-z]{24,}|glpat-[\w-]{20,}|npm_[A-Za-z0-9]{36}|dckr_pat_[\w-]{20,}|SG\.[\w-]{20,}\.[\w-]{20,}|hf_[A-Za-z0-9]{30,}|ya29\.[\w-]{20,}|eyJ[\w-]{1,512}\.[\w-]+\.[\w-]+)/g,
17 /(?<pre>\bauthorization["']?[ \t]*[:=][ \t]*(?:(?:basic|bearer|token|digest)[ \t]+)?)(?<v>[^\s'"]+)/gi,
18 /\b(?<pre>bearer[ \t]+)(?<v>[\w.~+/-]{8,}=*)/gi,
19 /(?<pre>:\/\/[^:\/\s@]+:)(?!\$)(?<v>[^\s@\/]+)(?<post>@)/g,
20 /(?<pre>(?:^|\s)(?:-u|--user)(?:[ \t]+|=)["']?[^\s:'"]{1,64}:)(?<v>[^\s'"]+)/g,
21 /(?<pre>\b(?:mysql|mysqldump|mariadb)\b[^\n]{0,200}?[ \t]-p)(?<v>[^\s'"]+)/g,
22 new RegExp(String.raw`(?<pre>--(?:password|token|api-key|secret)[ \t]+)(?![$-])(?<v>${VAL})`, 'gi'),
23 new RegExp(String.raw`(?<pre>\b(?<name>${NAME})["']?[ \t]*[=:][ \t]*)(?:(?<q>["'])(?!\$)(?<v>(?:(?!\k<q>)[^\n])+)(?<post>\k<q>?)|${CODE}(?<v2>${VAL}))`, 'gi'),
24]
25export const scrub = (s: string) => SCRUB.reduce((acc, re) => acc.replace(re, (m: string, ...rest: unknown[]) => {
26 const g = rest.at(-1) as Record<string, string | undefined> | string
27 return typeof g === 'object' && g ? `${g.pre ?? ''}${g.q ?? ''}***${g.post ?? ''}` : '***'
28}), s)
29