SLOPSHOPPER

event-log

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…

panecommandtoastprompttimer
v0.1.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/event-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · event-log
│ ┃ Event log ✕ › fix the failing auth test and add an audit log ╭─────────────────╮ │ ┃ all · 0 rows · /Users/dev/.claude/event-log… │ event-log │ │ ┃ No events yet. ⏺ Read(src/auth.ts) │ event log shown │ │ ⎿ 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 │ │ › /eventlog │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Event log
all · 0 rows · /Users/dev/.claude/event-log/preview-session No events yet.
README

event-log

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.

What it records

  • Rows: each event gets an 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.
  • Payloads: in full, each string cut at 16 KiB. Secret-shaped text (tokens, keys, passwords, Authorization headers) is masked as *** on a best-effort basis.
  • Counted, not stored: screen events (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.

Install

/plugin install event-log --marketplace dboeter/cc-plugins

Answer y to add the marketplace, then pick a scope.

Use

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.

What it does on your machine

  • Writes: ~/.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.
  • Reads: its own last chunk after a plugin reload, to carry on where it left off. It makes no model requests and no network calls.

Develop

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.

Source 2 files
hooks/event-log.tsx 337 lines
1import 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}
337
hooks/scrub.ts 29 lines
1// 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