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…

newcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · universal-audit-log
› 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 › /universal-audit-log ⎿ universal-audit-log: universal-audit-log esta ativado. Uso: /universal-audit-log on · /universal-audit-log off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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.

/universal-audit-log on|off toggles recording for the session (so meus-mods can control it too); turning it off stops recording new lines without touching whatever is already flushed to disk.

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


Vendored from davila7/claude-code-templates (cli-tool/components/mods/observability/universal-audit-log), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.

Source 2 files
hooks/register.ts 249 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; a relative path is taken from the working directory (default <CLAUDE_CONFIG_DIR or ~/.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 *
30 * Vendored from davila7/claude-code-templates (cli-tool/components/mods/observability/universal-audit-log),
31 * MIT licensed. Added: an isEnabled toggle (/universal-audit-log on|off) so meus-mods can control it.
32 */
33import { atom, read, update } from 'claude-code'
34import type { EngineInterface, Register } from 'claude-code'
35
36const isEnabledRef = atom({ plugin: 'universal-audit-log', key: 'isEnabled' } as const, true)
37
38/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
39function strings(value: unknown): string[] | undefined {
40  const list = Array.isArray(value)
41    ? value.filter((v): v is string => typeof v === 'string')
42    : typeof value === 'string'
43      ? value.split(',').map((s) => s.trim()).filter(Boolean)
44      : []
45  return list.length > 0 ? list : undefined
46}
47
48const MAX_FIELD = 200
49const encoder = new TextEncoder()
50const bytes = (text: string) => encoder.encode(text).length
51
52// Credentials never reach the file. Patterns from scasella/claude-flightdeck
53// (hooks/core.ts redact) plus the cofre mod's (hooks/secrets.ts PATTERNS):
54// mods are separate packages, so they are copied rather than imported.
55const SECRETS: [RegExp, string][] = [
56  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '•••'],
57  [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
58  [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
59  [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
60  [/AIza[\w-]{35}|AKIA[0-9A-Z]{16}|eyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]{10,}/g, '•••'],
61  [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
62  [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
63  [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
64  [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
65]
66export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
67
68// redact before cutting: a cut token would no longer match its pattern
69const clip = (s: string) => {
70  const r = redact(s)
71  return r.length > MAX_FIELD ? r.slice(0, MAX_FIELD) + '…' : r
72}
73
74const str = (v: unknown) => (typeof v === 'string' ? v : '')
75
76/** What a tool.check input touched (its command, path, pattern...), as Flightdeck's describeInput. */
77function describe(input: object): string {
78  const i: Record<string, unknown> = Object.fromEntries(Object.entries(input))
79  return str(i.command) || str(i.file_path) || str(i.path) || str(i.pattern) || str(i.url) || str(i.description)
80}
81
82function summarize(e: unknown): Record<string, unknown> {
83  if (!e || typeof e !== 'object') return { value: clip(String(e)) }
84  const out: Record<string, unknown> = {}
85  for (const [k, v] of Object.entries(e)) {
86    if (typeof v === 'string') out[k] = clip(v)
87    else if (typeof v === 'number' || typeof v === 'boolean') out[k] = v
88    else if (Array.isArray(v)) out[k] = `[array:${v.length}]`
89    else if (v && typeof v === 'object') out[k] = k === 'input' && describe(v) ? clip(describe(v)) : '[object]'
90  }
91  return out
92}
93
94function denialOf(result: unknown): string | undefined {
95  if (result && typeof result === 'object' && 'deny' in result && typeof result.deny === 'string') return result.deny
96  return undefined
97}
98
99/** Says a warning once per key: a path still unresolved is retried, and re-warned, on every flush. */
100function warnOnce($: EngineInterface, warned: Set<string>, key: string, text: string): void {
101  if (warned.has(key)) return
102  warned.add(key)
103  $.ui.log(`[universal-audit-log] ${text}`)
104}
105
106/** A drive (C:/), root (/) or UNC (//host) path; anything else resolves against the working directory. */
107const absolute = (p: string) => /^([A-Za-z]:)?\//.test(p.replace(/\\/g, '/'))
108
109/**
110 * The default log, absolute under the Claude config dir, or '' when no home can
111 * be found. Never a path relative to the working directory: that scattered one
112 * log per folder a session was opened in.
113 */
114async function defaultLogPath($: EngineInterface, warned: Set<string>): Promise<string> {
115  // $.env.get takes literal names only; a withheld variable reads as unset, so the next is still tried.
116  const unset = () => undefined
117  // An unset variable resolves to undefined; only a denial throws. A denied
118  // CLAUDE_CONFIG_DIR would silently move the log to the home, so it says so.
119  const configured = await $.env.get('CLAUDE_CONFIG_DIR').catch((err: unknown) => {
120    warnOnce($, warned, 'withheld', `CLAUDE_CONFIG_DIR withheld, falling back to the home: ${err instanceof Error ? err.message : String(err)}`)
121    return undefined
122  })
123  // A relative dir would put the log under whatever folder the session was opened in.
124  if (configured && !absolute(configured)) warnOnce($, warned, 'relative', `CLAUDE_CONFIG_DIR "${configured}" is not absolute, falling back to the home`)
125  const explicit = configured && absolute(configured) ? configured : undefined
126  // The engine does not hand HOME to mods on Windows, hence USERPROFILE.
127  const home = (await $.env.get('HOME').catch(unset)) || (await $.env.get('USERPROFILE').catch(unset))
128  const dir = explicit || (home && absolute(home) ? `${home}/.claude` : '')
129  return dir ? `${dir.replace(/\\/g, '/')}/logs/mods-audit.jsonl` : ''
130}
131
132export const register: Register = (on, options) => {
133  // Resolved on the first flush, since the default needs $.
134  let logPath = typeof options.path === 'string' ? options.path : ''
135  const skip = new Set<string>(
136    strings(options.skipEvents) ??
137      // turn.step is a stream (an async generator hook); a plain hook passes it through untouched.
138      ['ui.log', 'ui.render', 'ui.resolve', 'ui.invalidate', 'ui.status', 'clock.now', 'turn.step'],
139  )
140  const flushEvery = typeof options.flushEvery === 'number' ? options.flushEvery : 50
141  // $.fs.read stops at 4 MiB: the file must stay under that or the next flush could not read it back
142  const maxBytes = Math.min(typeof options.maxBytes === 'number' ? options.maxBytes : 2 * 1024 * 1024, 3 * 1024 * 1024)
143
144  const buffer: string[] = []
145  // flushes are read-modify-write: run them one after another, never two at once
146  let flushing: Promise<void> = Promise.resolve()
147  // every flush retries the path while it is unresolved: each warning is said once, not every turn
148  const warned = new Set<string>()
149
150  on('session.start', async ($, e, next) => {
151    const saved = await $.store.get('isEnabled').catch(() => undefined)
152    await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
153    await $.command.register({
154      name: 'universal-audit-log',
155      description: 'Liga/desliga o registro de auditoria de todos os eventos em JSONL.',
156      argumentHint: 'on|off',
157    })
158    return next(e)
159  })
160
161  on('command.run', { command: 'universal-audit-log' }, async ($, e) => {
162    const arg = e.args.trim().toLowerCase()
163    if (arg !== 'on' && arg !== 'off') {
164      const isEnabled = await read($, isEnabledRef)
165      return { text: `universal-audit-log esta ${isEnabled ? 'ativado' : 'desativado'}. Uso: /universal-audit-log on · /universal-audit-log off` }
166    }
167    await update($, isEnabledRef, () => arg === 'on')
168    await $.store.set('isEnabled', arg === 'on') // $.state is per session; the store survives a restart
169    return { text: `universal-audit-log ${arg === 'on' ? 'ativado' : 'desativado'}.` }
170  })
171
172  on('*', async ($, e, next) => {
173    if (skip.has(next.event)) return next(e)
174    // $.state is not wired yet at engine.create (the bootstrap event this
175    // wildcard hook also sees): fail open and keep logging rather than throw.
176    let isEnabled = true
177    try {
178      isEnabled = await read($, isEnabledRef)
179    } catch {
180      isEnabled = true
181    }
182    if (!isEnabled) return next(e)
183
184    const startedAt = Date.now()
185    let outcome = 'ok'
186    try {
187      const result = await next(e)
188      const denied = denialOf(result)
189      // a denial or an error message can quote the command it refused: redacted like the input
190      if (denied !== undefined) outcome = `denied: ${clip(denied)}`
191      return result
192    } catch (err) {
193      outcome = `threw: ${clip(err instanceof Error ? err.message : String(err))}`
194      throw err
195    } finally {
196      const line =
197        JSON.stringify({
198          ts: new Date(startedAt).toISOString(),
199          event: next.event,
200          origin: next.origin,
201          durationMs: Date.now() - startedAt,
202          outcome,
203          input: summarize(e),
204        }) + '\n'
205      // a record that alone would not fit the file is dropped rather than kept forever
206      if (bytes(line) <= maxBytes) buffer.push(line)
207
208      if (next.event === 'turn.complete' || buffer.length >= flushEvery) {
209        const pending = buffer.splice(0, buffer.length).join('')
210        flushing = flushing.then(async () => {
211          // Kept only once resolved: a miss is retried on the next flush.
212          logPath ||= await defaultLogPath($, warned)
213          if (!logPath) {
214            // ponytail: held lines grow without bound while no home resolves; cap at maxBytes if that ever happens for real.
215            // Accepted: a session that ends before a home resolves loses them, rather than scatter a log into the cwd.
216            buffer.unshift(pending)
217            warnOnce($, warned, 'no-home', 'no CLAUDE_CONFIG_DIR, HOME or USERPROFILE to put the log under; keeping the lines for the next flush')
218            return
219          }
220          try {
221            let prior = ''
222            if (await $.fs.exists(logPath)) {
223              try {
224                prior = await $.fs.read(logPath)
225              } catch (err) {
226                // an existing file that cannot be read back is replaced, and the replacement says so
227                $.ui.log(`[universal-audit-log] ${logPath} could not be read back, starting it over: ${err instanceof Error ? err.message : String(err)}`)
228              }
229            }
230            let text = prior + pending
231            // The limit is UTF-8 bytes, the size on disk: drop whole lines from the front until the tail fits.
232            while (bytes(text) > maxBytes) {
233              const cut = text.indexOf('\n')
234              if (cut === -1) { text = ''; break }
235              text = text.slice(cut + 1)
236            }
237            await $.fs.write(logPath, text)
238          } catch (err) {
239            // $.fs withheld by an admin plugin, or the path unwritable: keep the lines for the next flush.
240            buffer.unshift(pending)
241            $.ui.log(`[universal-audit-log] could not write ${logPath}: ${err instanceof Error ? err.message : String(err)}`)
242          }
243        })
244        await flushing
245      }
246    }
247  })
248}
249
types/index.d.ts 12 lines
1export type UniversalAuditLogState = {
2  isEnabled: boolean
3}
4
5declare module 'claude-code' {
6  interface PluginState {
7    'universal-audit-log': {
8      isEnabled: boolean
9    }
10  }
11}
12