evidence-hook: every claim with a receipt A developer released evidence-hook, an open-source hook for Claude Code that blocks a session from stopping when the assistant's claims lack supporting tool-call evidence in the transcript. The hook tracks UserPromptSubmit and Stop events, reads Claude Code's JSONL transcripts, and returns a block decision with a reason when a claim has no receipt; the accompanying test suite runs offline against fixture transcripts in a temp directory. | | /usr/bin/env node | | | // Tests for evidence-hook.mjs. Run: node evidence-hook.test.mjs | | | // Offline: fixture transcripts in the JSONL shape Claude Code writes, in a temp folder. | | | import fs from 'node:fs'; | | | import os from 'node:os'; | | | import path from 'node:path'; | | | import { spawnSync } from 'node:child process'; | | | import { fileURLToPath } from 'node:url'; | | | | | | const ROOT = fs.mkdtempSync path.join os.tmpdir , 'evidence-hook-test-' ; | | | Object.assign process.env, { TMPDIR: ROOT, TEMP: ROOT, TMP: ROOT } ; // the hook keeps its state under os.tmpdir | | | delete process.env.CLAUDE PROJECT DIR; // the run layer cases set it themselves | | | fs.writeFileSync path.join ROOT, 'gitconfig' , '' ; // git sees only the test repos' own config | | | Object.assign process.env, { GIT CONFIG GLOBAL: path.join ROOT, 'gitconfig' , GIT CONFIG NOSYSTEM: '1' } ; | | | const TDIR = path.join ROOT, 't' ; fs.mkdirSync TDIR ; // transcripts, apart from the files the cases read | | | const repo = ...kv = { const d = fs.mkdtempSync path.join ROOT, 'repo-' ; spawnSync 'git', 'init', '-q', d ; for const k, v of kv spawnSync 'git', 'config', k, v , { cwd: d } ; return d; }; | | | const DIR = path.dirname fileURLToPath import.meta.url ; | | | const HOOK = path.join DIR, 'evidence-hook.mjs' ; | | | const H = await import HOOK ; | | | | | | let passed = 0; let total = 0; | | | function check name, ok, detail = '' { total++; if ok passed++; console.log ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ${detail} } ; } | | | | | | // Transcript entries, in the shape Claude Code writes them. | | | const P = Date.now ; | | | const at = k = new Date P + 1000 + k 1000 .toISOString ; | | | const old = k = new Date P - 3600e3 + k 1000 .toISOString ; | | | const use = id, name, input, t = { type: 'assistant', timestamp: t, isSidechain: false, message: { role: 'assistant', content: { type: 'tool use', id, name, input } } } ; | | | const res = id, text, t, extra = {} = { | | | const block = { type: 'tool result', tool use id: id, content: extra.array ? { type: 'text', text } : text }; | | | if extra.isError block.is error = true; | | | const e = { type: 'user', timestamp: t, isSidechain: false, cwd: extra.cwd \|\| '/r', message: { role: 'user', content: block } }; | | | if extra.toolUseResult e.toolUseResult = extra.toolUseResult; | | | return e; | | | }; | | | const human = text, t = { type: 'user', timestamp: t, isSidechain: false, message: { role: 'user', content: text }, origin: { kind: 'human' } } ; | | | const say = text, t = { type: 'assistant', timestamp: t, isSidechain: false, message: { role: 'assistant', content: { type: 'text', text } } } ; | | | | | | let n = 0; | | | // One case: a user prompt UserPromptSubmit , a transcript, then Stop. | | | function stop { entries = , reply, sid, noPrompt, promptAt, cwd = '/r', opts = {} } { | | | sid = sid \|\| case-${++n} ; | | | if noPrompt H.run { hook event name: 'UserPromptSubmit', session id: sid, prompt: 'go on' } ; | | | if promptAt fs.writeFileSync path.join ROOT, 'evidence-hook', ${sid}.json , JSON.stringify { promptAt, blocks: 0 } ; | | | const file = path.join TDIR, ${sid}.jsonl ; | | | fs.writeFileSync file, entries.map e = JSON.stringify e .join '\n' + '\n' ; | | | const out = H.run { hook event name: 'Stop', session id: sid, transcript path: file, cwd, last assistant message: reply }, { retryMs: 0, ...opts } ; | | | return { out, sid }; | | | } | | | const blocked = o = o.out.includes '"decision":"block"' ; | | | const reason = o = blocked o ? JSON.parse o.out .reason : '' ; | | | | | | const RD = 'line 7: settings change only on a branch'; | | | const readAt = k = use r${k} , 'Read', { file path: '/r/a.md' }, at k , res r${k} , RD, at k + 1 ; | | | | | | { | | | const a = stop { entries: readAt 1 , reply: Settings change on a branch.\nEVIDENCE read \| "${RD}" } ; | | | check 'valid read via Read passes', blocked a , a.out ; | | | const b = stop { entries: readAt 1 , reply: 'EVIDENCE read \| "this passage appears nowhere at all"' } ; | | | check 'passage in no tool result blocks', blocked b && reason b .includes 'found in no tool result of this turn' , b.out ; | | | const c = stop { entries: use 'o', 'Read', { file path: '/r/a.md' }, old 1 , res 'o', RD, old 2 , human 'next', old 3 , reply: EVIDENCE read \| "${RD}" } ; | | | check 'passage only in an earlier turn blocks', blocked c && reason c .includes 'found in no tool result of this turn' , c.out ; | | | const d = stop { entries: readAt 1 , reply: EVIDENCE rule \| "${RD}"\nEvidence read \| "${RD}"\nEVIDENCE read \| "too short" } ; | | | check 'malformed lines block unknown layer, wrong case, passage under 12 characters , with the form line', | | | blocked d && reason d .split '\n' .filter l = l.startsWith 'malformed:' .length === 3 && reason d .includes 'Write: EVIDENCE