SLOPSHOPPER

universal-audit-log

One hook on * that records every event on $ (tool calls, prompts, turns, other plugins' fs/http/process calls) as JSON lines with origin plugin and tier…

new
A shopper browsing a rack in a slop shop
README

universal-audit-log

One hook on "*" sees every event: the engine's own (tool.call, prompt.submit, turn.*, ...) and every other plugin's calls on $ (fs.read, http.fetch, process.run, ...). It records one JSON line per dispatch: the event, who raised it (next.origin: plugin and tier), how long it took and whether something beneath denied or threw. The "after" placement, so the outcome is recorded too; denials surface here as the value next(e) resolves to.

Lines are buffered in memory and flushed to a JSONL file through $.fs (there is no append on $, so a flush reads the file, keeps its tail under maxBytes, and writes it back) at the end of every turn and every flushEvery events. The hook is never re-entered for its own $.fs calls, so flushing from inside it does not recurse.

Seat this plugin FIRST (an org prepends it in managed settings) so nothing beneath it can bypass the log. A hook that fails is skipped by the engine (fail-open, logged in --debug-file), so it never blocks the session.

Options

  path:       string    JSONL file, relative to the working directory (default ".claude/logs/mods-audit.jsonl")
  skipEvents: string    events not to record, comma-separated (default: the render / log chatter)
  flushEvery: number    flush after this many buffered lines (default 50)
  maxBytes:   number    keep the file under this size, dropping the oldest lines (default 2 MiB)

Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:

{ "pluginConfigs": { "universal-audit-log@skills-dir": { "options": { } } } }

Install

npx claude-code-templates@latest --mod observability/universal-audit-log
claude

It is written to .claude/skills/universal-audit-log/, which Claude Code auto-loads as universal-audit-log@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/universal-audit-log. claude plugin validate .claude/skills/universal-audit-log prints every event it hooks and every $ call it makes.

Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods

Source 1 files
hooks/universal-audit-log.ts 136 lines
1/**
2 * universal-audit-log — Claude Mod
3 *
4 * One hook on "*" sees every event: the engine's own (tool.call, prompt.submit,
5 * turn.*, ...) and every other plugin's calls on `$` (fs.read, http.fetch,
6 * process.run, ...). It records one JSON line per dispatch: the event, who
7 * raised it (`next.origin`: plugin and tier), how long it took and whether
8 * something beneath denied or threw. The "after" placement, so the outcome is
9 * recorded too; denials surface here as the value `next(e)` resolves to.
10 *
11 * Lines are buffered in memory and flushed to a JSONL file through `$.fs`
12 * (there is no append on `$`, so a flush reads the file, keeps its tail under
13 * `maxBytes`, and writes it back) at the end of every turn and every
14 * `flushEvery` events. The hook is never re-entered for its own `$.fs` calls,
15 * so flushing from inside it does not recurse.
16 *
17 * Seat this plugin FIRST (an org prepends it in managed settings) so nothing
18 * beneath it can bypass the log. A hook that fails is skipped by the engine
19 * (fail-open, logged in --debug-file), so it never blocks the session.
20 *
21 * Needs Claude Code >= 2.1.287. Typed
22 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
23 *
24 * Options:
25 *   path:       string    JSONL file, relative to the working directory (default ".claude/logs/mods-audit.jsonl")
26 *   skipEvents: string    events not to record, comma-separated (default: the render / log chatter)
27 *   flushEvery: number    flush after this many buffered lines (default 50)
28 *   maxBytes:   number    keep the file under this many UTF-8 bytes, dropping the oldest lines (default 2 MiB, at most 3 MiB)
29 */
30import type { Register } from 'claude-code'
31
32/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
33function strings(value: unknown): string[] | undefined {
34  const list = Array.isArray(value)
35    ? value.filter((v): v is string => typeof v === 'string')
36    : typeof value === 'string'
37      ? value.split(',').map((s) => s.trim()).filter(Boolean)
38      : []
39  return list.length > 0 ? list : undefined
40}
41
42const MAX_FIELD = 200
43const encoder = new TextEncoder()
44const bytes = (text: string) => encoder.encode(text).length
45
46function summarize(e: unknown): Record<string, unknown> {
47  if (!e || typeof e !== 'object') return { value: String(e).slice(0, MAX_FIELD) }
48  const out: Record<string, unknown> = {}
49  for (const [k, v] of Object.entries(e)) {
50    if (typeof v === 'string') out[k] = v.length > MAX_FIELD ? v.slice(0, MAX_FIELD) + '…' : v
51    else if (typeof v === 'number' || typeof v === 'boolean') out[k] = v
52    else if (Array.isArray(v)) out[k] = `[array:${v.length}]`
53    else if (v && typeof v === 'object') out[k] = '[object]'
54  }
55  return out
56}
57
58function denialOf(result: unknown): string | undefined {
59  if (result && typeof result === 'object' && 'deny' in result && typeof result.deny === 'string') return result.deny
60  return undefined
61}
62
63export const register: Register = (on, options) => {
64  const logPath = typeof options.path === 'string' ? options.path : '.claude/logs/mods-audit.jsonl'
65  const skip = new Set<string>(
66    strings(options.skipEvents) ??
67      // turn.step is a stream (an async generator hook); a plain hook passes it through untouched.
68      ['ui.log', 'ui.render', 'ui.resolve', 'ui.invalidate', 'ui.status', 'clock.now', 'turn.step'],
69  )
70  const flushEvery = typeof options.flushEvery === 'number' ? options.flushEvery : 50
71  // $.fs.read stops at 4 MiB: the file must stay under that or the next flush could not read it back
72  const maxBytes = Math.min(typeof options.maxBytes === 'number' ? options.maxBytes : 2 * 1024 * 1024, 3 * 1024 * 1024)
73
74  const buffer: string[] = []
75  // flushes are read-modify-write: run them one after another, never two at once
76  let flushing: Promise<void> = Promise.resolve()
77
78  on('*', async ($, e, next) => {
79    if (skip.has(next.event)) return next(e)
80
81    const startedAt = Date.now()
82    let outcome = 'ok'
83    try {
84      const result = await next(e)
85      const denied = denialOf(result)
86      if (denied !== undefined) outcome = `denied: ${denied}`
87      return result
88    } catch (err) {
89      outcome = `threw: ${err instanceof Error ? err.message : String(err)}`
90      throw err
91    } finally {
92      const line =
93        JSON.stringify({
94          ts: new Date(startedAt).toISOString(),
95          event: next.event,
96          origin: next.origin,
97          durationMs: Date.now() - startedAt,
98          outcome,
99          input: summarize(e),
100        }) + '\n'
101      // a record that alone would not fit the file is dropped rather than kept forever
102      if (bytes(line) <= maxBytes) buffer.push(line)
103
104      if (next.event === 'turn.complete' || buffer.length >= flushEvery) {
105        const pending = buffer.splice(0, buffer.length).join('')
106        flushing = flushing.then(async () => {
107          try {
108            let prior = ''
109            if (await $.fs.exists(logPath)) {
110              try {
111                prior = await $.fs.read(logPath)
112              } catch (err) {
113                // an existing file that cannot be read back is replaced, and the replacement says so
114                $.ui.log(`[universal-audit-log] ${logPath} could not be read back, starting it over: ${err instanceof Error ? err.message : String(err)}`)
115              }
116            }
117            let text = prior + pending
118            // The limit is UTF-8 bytes, the size on disk: drop whole lines from the front until the tail fits.
119            while (bytes(text) > maxBytes) {
120              const cut = text.indexOf('\n')
121              if (cut === -1) { text = ''; break }
122              text = text.slice(cut + 1)
123            }
124            await $.fs.write(logPath, text)
125          } catch (err) {
126            // $.fs withheld by an admin plugin, or the path unwritable: keep the lines for the next flush.
127            buffer.unshift(pending)
128            $.ui.log(`[universal-audit-log] could not write ${logPath}: ${err instanceof Error ? err.message : String(err)}`)
129          }
130        })
131        await flushing
132      }
133    }
134  })
135}
136