{"slug": "evidence-hook-every-claim-with-a-receipt", "title": "evidence-hook: every claim with a receipt", "summary": "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.", "body_md": "|  | #!/usr/bin/env node | \n|  | // Tests for evidence-hook.mjs. Run: node evidence-hook.test.mjs | \n|  | // Offline: fixture transcripts in the JSONL shape Claude Code writes, in a temp folder. | \n|  | import fs from 'node:fs'; | \n|  | import os from 'node:os'; | \n|  | import path from 'node:path'; | \n|  | import { spawnSync } from 'node:child_process'; | \n|  | import { fileURLToPath } from 'node:url'; | \n|  |  | \n|  | const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-hook-test-')); | \n|  | Object.assign(process.env, { TMPDIR: ROOT, TEMP: ROOT, TMP: ROOT }); // the hook keeps its state under os.tmpdir() | \n|  | delete process.env.CLAUDE_PROJECT_DIR; // the run layer cases set it themselves | \n|  | fs.writeFileSync(path.join(ROOT, 'gitconfig'), ''); // git sees only the test repos' own config | \n|  | Object.assign(process.env, { GIT_CONFIG_GLOBAL: path.join(ROOT, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }); | \n|  | const TDIR = path.join(ROOT, 't'); fs.mkdirSync(TDIR); // transcripts, apart from the files the cases read | \n|  | 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; }; | \n|  | const DIR = path.dirname(fileURLToPath(import.meta.url)); | \n|  | const HOOK = path.join(DIR, 'evidence-hook.mjs'); | \n|  | const H = await import(HOOK); | \n|  |  | \n|  | let passed = 0; let total = 0; | \n|  | function check(name, ok, detail = '') { total++; if (ok) passed++; console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `  ${detail}`}`); } | \n|  |  | \n|  | // Transcript entries, in the shape Claude Code writes them. | \n|  | const P = Date.now(); | \n|  | const at = k => new Date(P + 1000 + k * 1000).toISOString(); | \n|  | const old = k => new Date(P - 3600e3 + k * 1000).toISOString(); | \n|  | const use = (id, name, input, t) => ({ type: 'assistant', timestamp: t, isSidechain: false, message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } }); | \n|  | const res = (id, text, t, extra = {}) => { | \n|  | const block = { type: 'tool_result', tool_use_id: id, content: extra.array ? [{ type: 'text', text }] : text }; | \n|  | if (extra.isError) block.is_error = true; | \n|  | const e = { type: 'user', timestamp: t, isSidechain: false, cwd: extra.cwd \\|\\| '/r', message: { role: 'user', content: [block] } }; | \n|  | if (extra.toolUseResult) e.toolUseResult = extra.toolUseResult; | \n|  | return e; | \n|  | }; | \n|  | const human = (text, t) => ({ type: 'user', timestamp: t, isSidechain: false, message: { role: 'user', content: text }, origin: { kind: 'human' } }); | \n|  | const say = (text, t) => ({ type: 'assistant', timestamp: t, isSidechain: false, message: { role: 'assistant', content: [{ type: 'text', text }] } }); | \n|  |  | \n|  | let n = 0; | \n|  | // One case: a user prompt (UserPromptSubmit), a transcript, then Stop. | \n|  | function stop({ entries = [], reply, sid, noPrompt, promptAt, cwd = '/r', opts = {} }) { | \n|  | sid = sid \\|\\| `case-${++n}`; | \n|  | if (!noPrompt) H.run({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'go on' }); | \n|  | if (promptAt) fs.writeFileSync(path.join(ROOT, 'evidence-hook', `${sid}.json`), JSON.stringify({ promptAt, blocks: 0 })); | \n|  | const file = path.join(TDIR, `${sid}.jsonl`); | \n|  | fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\\n') + '\\n'); | \n|  | const out = H.run({ hook_event_name: 'Stop', session_id: sid, transcript_path: file, cwd, last_assistant_message: reply }, { retryMs: 0, ...opts }); | \n|  | return { out, sid }; | \n|  | } | \n|  | const blocked = o => o.out.includes('\"decision\":\"block\"'); | \n|  | const reason = o => (blocked(o) ? JSON.parse(o.out).reason : ''); | \n|  |  | \n|  | const RD = 'line 7: settings change only on a branch'; | \n|  | const readAt = k => [use(`r${k}`, 'Read', { file_path: '/r/a.md' }, at(k)), res(` r${k}`, RD, at(k + 1))]; | \n|  |  | \n|  | { | \n|  | const a = stop({ entries: readAt(1), reply: `Settings change on a branch.\\nEVIDENCE read \\| \"${RD}\"` }); | \n|  | check('valid read via Read passes', !blocked(a), a.out); | \n|  | const b = stop({ entries: readAt(1), reply: 'EVIDENCE read \\| \"this passage appears nowhere at all\"' }); | \n|  | check('passage in no tool result blocks', blocked(b) && reason(b).includes('found in no tool result of this turn'), b.out); | \n|  | 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}\"` }); | \n|  | check('passage only in an earlier turn blocks', blocked(c) && reason(c).includes('found in no tool result of this turn'), c.out); | \n|  | const d = stop({ entries: readAt(1), reply: `EVIDENCE rule \\| \"${RD}\"\\nEvidence read \\| \"${RD}\"\\nEVIDENCE read \\| \"too short\"` }); | \n|  | check('malformed lines block (unknown layer, wrong case, passage under 12 characters), with the form line', | \n|  | blocked(d) && reason(d).split('\\n').filter(l => l.startsWith('malformed:')).length === 3 && reason(d).includes('Write: EVIDENCE <layer>'), d.out); | \n|  | const e = stop({ entries: readAt(1), reply: `- EVIDENCE read \\| \"${RD}\"\\n> **EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('markdown list and quote marks before a well-formed line are accepted', !blocked(e), e.out); | \n|  | const f = stop({ entries: readAt(1), reply: 'Evidence shows the bug is in X.\\nEvidence is thin \\| so I read it again.\\nEVIDENCES differ here.' }); | \n|  | check('prose led by Evidence is not an EVIDENCE line', f.out === '', f.out); | \n|  | const g = stop({ entries: readAt(1), reply: `evidence: read \\| \"${RD}\"\\n**Evidence:** read \\| \"${RD}\"\\nEVIDENCE read \"${RD}\"` }); | \n|  | check('evidence, an optional colon, one word and a \\| (any case), or the word EVIDENCE and a space, is malformed', | \n|  | blocked(g) && reason(g).split('\\n').filter(l => l.startsWith('malformed:')).length === 3, g.out); | \n|  | } | \n|  | { | \n|  | const t = 'all suites green, 41 of 41 here'; | \n|  | const a = stop({ entries: [use('e', 'Bash', { command: `echo \"${t}\"` }, at(1)), res('e', t, at(2))], reply: `EVIDENCE read \\| \"${t}\"` }); | \n|  | check('echo output is not read', blocked(a) && reason(a).includes('found only as layer none'), a.out); | \n|  | const g = 'abc1234 fix the build script'; | \n|  | const b = stop({ entries: [use('g', 'Bash', { command: 'git log --oneline -3' }, at(1)), res('g', g, at(2), { cwd: repo() })], reply: `EVIDENCE read \\| \"${g}\"` }); | \n|  | check('allowlisted git log is read', !blocked(b), b.out); | \n|  | const bad = ['cat a; echo x', 'cat a \\| head', 'cat $HOME', 'cat `x`', 'cat a > b', 'git log --format x', 'git log --pretty=x', 'find .', 'stat f', 'git -c a=b log', 'node t.mjs']; | \n|  | check('chains, redirects, expansions, caller-text options and other programs are not read', bad.every(c => H.bashRead(c) === null), bad.filter(c => H.bashRead(c) !== null).join(' \\| ')); | \n|  | const body = 'model-written text, two lines long'; | \n|  | const c = stop({ entries: [use('w', 'Write', { file_path: '/r/new.md', content: body }, at(1)), res('w', 'File created successfully', at(2)), | \n|  | use('c', 'Bash', { command: 'cat new.md' }, at(3)), res('c', body, at(4))], reply: `EVIDENCE read \\| \"${body}\"` }); | \n|  | check('a Bash read of a path written earlier this turn is not read', blocked(c) && reason(c).includes('found only as layer none'), c.out); | \n|  | const refused = ['cat fo*.txt', 'cat a?.md', 'cat [ab].md', 'cat a.md #x', 'cat a\\\\ b.md', 'cat ~/a.md', 'ls ~', 'ls -D %s a', 'ls --time-style x a', | \n|  | 'git log --date x', 'git log --format x', 'git log --pretty x', 'git log --output x', 'git -C /x log', 'git -c x log', 'git diff --ext-diff', | \n|  | 'git show --textconv', 'git diff --no-index a b', 'grep --label x y a.md', 'tail -f a.md', 'head -n x a.md', 'git branch new']; | \n|  | check('allowlist: globs, escapes, ~ at word start and options off the tables are refused', refused.every(x => H.bashRead(x) === null), refused.filter(x => H.bashRead(x) !== null).join(' \\| ')); | \n|  | const kept = [['git log --oneline HEAD~1', ['HEAD~1']], ['cat \"a b.md\"', ['a b.md']], ['grep -n -e x a.md', ['a.md']], ['grep -rn x', ['.']], | \n|  | ['head -n 5 a.md', ['a.md']], ['tail -n +3 a.md', ['a.md']], ['git show -s --stat HEAD', ['HEAD']], ['git branch -l x', ['x']], [\"grep 'a*b' c.md\", ['c.md']]]; | \n|  | check('allowlist: table options pass and give the path words (HEAD~1, quoted words kept)', kept.every(([x, w]) => JSON.stringify(H.bashRead(x)) === JSON.stringify(w)), | \n|  | kept.filter(([x, w]) => JSON.stringify(H.bashRead(x)) !== JSON.stringify(w)).map(([x]) => `${x} -> ${JSON.stringify(H.bashRead(x))}`).join(' \\| ')); | \n|  | const g2 = 'model-written text, read back by a glob'; | \n|  | const d = stop({ entries: [use('w2', 'Write', { file_path: '/r/foo.txt', content: g2 }, at(1)), res('w2', 'File created successfully', at(2)), | \n|  | use('c2', 'Bash', { command: 'cat fo*.txt' }, at(3)), res('c2', g2, at(4))], reply: `EVIDENCE read \\| \"${g2}\"` }); | \n|  | check('a file written this turn read back through a glob is not read', blocked(d) && reason(d).includes('found only as layer none'), d.out); | \n|  | } | \n|  | { | \n|  | const proj = fs.mkdtempSync(path.join(ROOT, 'proj-')); | \n|  | const cfg = (dir, body, when = old(0)) => { const f = path.join(dir, '.claude', 'evidence-commands.json'); fs.mkdirSync(path.dirname(f), { recursive: true }); | \n|  | fs.writeFileSync(f, body); fs.utimesSync(f, new Date(when), new Date(when)); return dir; }; // an mtime from before the turn, unless given | \n|  | cfg(proj, JSON.stringify({ commands: ['npm test'] })); | \n|  | const out1 = 'Tests: 2 failed, 9 passed, 11 total'; const out2 = 'Tests: 11 passed, 11 total'; | \n|  | const runAt = (id, cmd, t, k, isError) => [use(id, 'Bash', { command: cmd }, at(k)), res(id, t, at(k + 1), { isError })]; | \n|  | process.env.CLAUDE_PROJECT_DIR = proj; | \n|  | const a = stop({ entries: runAt('t1', 'npm test', out1, 1, true), reply: `EVIDENCE run \\| \"${out1}\"` }); | \n|  | check('run: a listed command (error result) is run, from CLAUDE_PROJECT_DIR', !blocked(a), a.out); | \n|  | const b = stop({ entries: [...runAt('t1', 'npm test', out1, 1, true), ...runAt('t2', 'npm test', out2, 3)], reply: `EVIDENCE run \\| \"${out1}\"\\nEVIDENCE run \\| \"${out2}\"` }); | \n|  | check('run: only the newest run of a command is fresh', blocked(b) && reason(b).split('\\n').filter(l => l.includes('older run')).length === 1 && !reason(b).includes(out2), b.out); | \n|  | const c = stop({ entries: [...runAt('t3', 'npm test -- x', out2, 1), ...runAt('t4', 'npm test', out1, 3)], reply: `EVIDENCE run \\| \"${out2}\"\\nEVIDENCE read \\| \"${out1}\"` }); | \n|  | check('run: a command that only starts with an entry is not run, and a run is not read', blocked(c) && reason(c).includes('found only as layer none') && reason(c).includes('found only as layer run'), c.out); | \n|  | delete process.env.CLAUDE_PROJECT_DIR; | \n|  | const d = stop({ cwd: proj, entries: runAt('t1', 'npm test', out1, 1), reply: `EVIDENCE run \\| \"${out1}\"` }); | \n|  | check('run: without CLAUDE_PROJECT_DIR the file is read from the input cwd', !blocked(d), d.out); | \n|  | const missing = fs.mkdtempSync(path.join(ROOT, 'none-')); const bad = cfg(fs.mkdtempSync(path.join(ROOT, 'bad-')), '{\"commands\": \"npm test\"'); | \n|  | const badShape = cfg(fs.mkdtempSync(path.join(ROOT, 'shape-')), JSON.stringify({ commands: ['npm test', 3] })); | \n|  | const e = [missing, bad, badShape].map(cwd => stop({ cwd, entries: runAt('t1', 'npm test', out1, 1), reply: `EVIDENCE run \\| \"${out1}\"` })); | \n|  | check('run: a missing, unparsable or wrongly shaped file gives no run layer', e.every(o => blocked(o) && reason(o).includes('found only as layer none')), e.map(o => o.out).join(' \\| ')); | \n|  | const f = [at(0), at(2)].map(when => stop({ cwd: cfg(fs.mkdtempSync(path.join(ROOT, 'late-')), JSON.stringify({ commands: ['npm test'] }), when), promptAt: at(0), | \n|  | entries: runAt('t1', 'npm test', out1, 1), reply: `EVIDENCE run \\| \"${out1}\"` })); | \n|  | check('A run list: a commands file with an mtime at or after the turn start gives no run layer', f.every(o => blocked(o) && reason(o).includes('found only as layer none')), f.map(o => o.out).join(' \\| ')); | \n|  | } | \n|  | { | \n|  | const g = 'abc1234 fix the build script'; | \n|  | const gitRead = cwd => [use('g', 'Bash', { command: 'git log --oneline -3' }, at(1)), res('g', g, at(2), { cwd })]; | \n|  | const bad = [['diff.external', 'x'], ['diff.x.textconv', 'x'], ['format.pretty', 'x'], ['log.date', 'format:%s'], ['core.fsmonitor', './watch.sh'], ['filter.x.clean', 'git-lfs clean -- %f']]; | \n|  | const a = bad.map(kv => stop({ entries: gitRead(repo(kv)), reply: `EVIDENCE read \\| \"${g}\"` })); | \n|  | check('B git config: a git read where the cwd config sets an external diff, textconv, pretty format, format: date, fsmonitor or a non-lfs filter is not read', | \n|  | a.every(o => blocked(o) && reason(o).includes('found only as layer none')), a.map((o, i) => blocked(o) ? '' : bad[i][0]).join(' ')); | \n|  | const stock = repo(['filter.lfs.clean', 'git-lfs clean -- %f'], ['filter.lfs.smudge', 'git-lfs smudge -- %f'], ['filter.lfs.process', 'git-lfs filter-process'], | \n|  | ['log.date', 'iso'], ['core.fsmonitor', 'false'], ['log.showSignature', 'false']); | \n|  | const b = stop({ entries: gitRead(stock), reply: `EVIDENCE read \\| \"${g}\"` }); | \n|  | check('B git config: stock git-lfs lines and harmless values keep a git read read', !blocked(b), b.out); | \n|  | } | \n|  | { | \n|  | const dir = fs.mkdtempSync(path.join(ROOT, 'files-')); const file = path.join(dir, 'c.md'); const body = 'line 3: the cache is cleared on start'; | \n|  | fs.writeFileSync(file, body); const touch = t => fs.utimesSync(file, new Date(t), new Date(t)); | \n|  | const cases = () => [[use('c1', 'Read', { file_path: file }, at(1)), res('c1', body, at(2))], [use('c2', 'Bash', { command: 'cat c.md' }, at(1)), res('c2', body, at(2), { cwd: dir })]] | \n|  | .map(entries => stop({ entries, reply: `EVIDENCE read \\| \"${body}\"` })); | \n|  | touch(old(0)); const a = cases(); | \n|  | check('C mtime: a Read or Bash read of a file unchanged since its result is read', a.every(o => !blocked(o)), a.map(o => o.out).join(' \\| ')); | \n|  | touch(at(3)); const b = cases(); | \n|  | check('C mtime: a Read or Bash read of a file with an mtime after its result is stale', b.every(o => blocked(o) && reason(o).includes('stale read')), b.map(o => o.out).join(' \\| ')); | \n|  | } | \n|  | { | \n|  | const said = 'I ran the suite: 41 of 41 green'; | \n|  | const tr = sid => path.join(TDIR, `${sid}.jsonl`); | \n|  | const cases = [['tr-read', 'Read', { file_path: tr('tr-read') }], ['tr-sub', 'Read', { file_path: path.join(TDIR, 'tr-sub', 'subagents', 'x.jsonl') }], | \n|  | ['tr-grep', 'Grep', { pattern: '41 of 41', path: TDIR }], ['tr-up', 'Grep', { pattern: '41 of 41', path: ROOT }], ['tr-bash', 'Bash', { command: `grep -n green ${tr('tr-bash')}` }]]; | \n|  | const a = cases.map(([sid, name, input]) => stop({ sid, entries: [use('x', name, input, at(1)), res('x', said, at(2))], reply: `EVIDENCE read \\| \"${said}\"` })); | \n|  | check('D transcript: a Read, Grep or Bash read of the transcript, a file under its folder, or a folder above it is not read', | \n|  | a.every(o => blocked(o) && reason(o).includes('found only as layer none')), a.map((o, i) => blocked(o) ? '' : cases[i][0]).join(' ')); | \n|  | } | \n|  | { | \n|  | const miss = 'cat: TOTAL 41 of 41 passed: No such file or directory'; const p = 'TOTAL 41 of 41 passed'; | \n|  | const a = stop({ entries: [use('x', 'Bash', { command: `cat \"${p}\"` }, at(1)), res('x', miss, at(2), { isError: true })], reply: `EVIDENCE read \\| \"${p}\"` }); | \n|  | check('E is_error: an allowlisted Bash read whose result is an error is not read', blocked(a) && reason(a).includes('found only as layer none'), a.out); | \n|  | } | \n|  | { | \n|  | const page = 'issue 42 is open, labeled bug, 3 comments'; | \n|  | const mcp = (isError) => [use('m1', 'mcp__tracker__get_issue', { id: 42 }, at(1)), res('m1', page, at(2), { isError })]; | \n|  | const a = stop({ entries: mcp(false), reply: `EVIDENCE mcp \\| \"${page}\"` }); | \n|  | check('mcp: an error-free mcp__ tool result is mcp', !blocked(a), a.out); | \n|  | const b = stop({ entries: mcp(true), reply: `EVIDENCE mcp \\| \"${page}\"` }); | \n|  | check('mcp: an mcp__ error result is tool, not mcp', blocked(b) && reason(b).includes('found only as layer tool'), b.out); | \n|  | const c = stop({ entries: mcp(false), reply: `EVIDENCE read \\| \"${page}\"` }); | \n|  | check('mcp: an mcp__ result cited as read blocks', blocked(c) && reason(c).includes('found only as layer mcp'), c.out); | \n|  | } | \n|  | { // the gist's companion files, when they sit next to the tests | \n|  | const snip = path.join(DIR, 'settings-snippet.json'); const readme = path.join(DIR, 'README.md'); | \n|  | if (fs.existsSync(snip)) { | \n|  | const s = JSON.parse(fs.readFileSync(snip, 'utf8')); const h = s.hooks; const want = 'node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/evidence-hook.mjs\"'; | \n|  | const cmds = ['UserPromptSubmit', 'Stop'].flatMap(k => (h[k] \\|\\| []).flatMap(g => g.hooks.map(x => x.command))); | \n|  | check('settings snippet runs the hook by ${CLAUDE_PROJECT_DIR} on both events', cmds.length === 2 && cmds.every(x => x === want), JSON.stringify(cmds)); | \n|  | const deny = (s.permissions && s.permissions.deny) \\|\\| []; | \n|  | check('A settings snippet denies Claude edits to the run list', deny.includes('Edit(/.claude/evidence-commands.json)'), JSON.stringify(deny)); | \n|  | } | \n|  | if (fs.existsSync(readme)) { | \n|  | const md = fs.readFileSync(readme, 'utf8'); const part = (a, b) => md.slice(md.indexOf(a), b ? md.indexOf(b) : undefined); | \n|  | const rule = part('## The rule', '## What it checks'); const checks = part('## What it checks', '## Known limits'); const limits = part('## Known limits'); | \n|  | const ok = H.LAYERS.every(l => rule.includes(`- ${l}:`) && checks.includes(`- ${l}:`)) && md.includes('evidence-commands.json') && !md.includes('relative to the project root') | \n|  | && ['last_assistant_message', 'then read with Read', 'echoes its input', 'script or test file', 'parsed whole', 'fires on each user message'].every(s => limits.includes(s)); | \n|  | check('README lists all six layers in the rule and the checks, the commands file and the limits', ok); | \n|  | const need = ['Edit(/.claude/evidence-commands.json)', 'all built-in tools that edit files', 'primary working directory', 'mtime', 'git config', 'transcript']; | \n|  | check('F README explains the run list deny rule and the new checks', need.every(x => md.includes(x)), need.filter(x => !md.includes(x)).join(' \\| ')); | \n|  | } | \n|  | } | \n|  | { | \n|  | const edit = k => [use(`e${k}`, 'Edit', { file_path: '/r/a.md', old_string: 'a', new_string: 'b' }, at(k)), res(`e${k}`, 'The file has been updated successfully.', at(k + 1))]; | \n|  | const a = stop({ entries: [...readAt(1), ...edit(3)], reply: `EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('Read then Edit of the same path is stale', blocked(a) && reason(a).includes('stale read'), a.out); | \n|  | const b = stop({ entries: [...edit(1), ...readAt(3)], reply: `EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('Edit then Read of the same path passes', !blocked(b), b.out); | \n|  | } | \n|  | { | \n|  | const rep = 'Builder reports: suite green, 12 of 12'; | \n|  | const a = stop({ entries: [use('ag', 'Agent', { prompt: 'x' }, at(1)), res('ag', rep, at(2), { array: true, toolUseResult: { status: 'completed' } })], reply: `EVIDENCE report \\| \"${rep}\"` }); | \n|  | check('Agent result is report', !blocked(a), a.out); | \n|  | const b = stop({ entries: [use('ag', 'Agent', { prompt: 'x' }, at(1)), res('ag', rep, at(2), { array: true, toolUseResult: { status: 'completed' } })], reply: `EVIDENCE read \\| \"${rep}\"` }); | \n|  | check('Agent result cited as read blocks', blocked(b) && reason(b).includes('found only as layer report'), b.out); | \n|  | const tn = { type: 'user', timestamp: at(3), isSidechain: false, message: { role: 'user', content: `<task-notification><result>${rep}</result></task-notification>` }, origin: { kind: 'task-notification' } }; | \n|  | const c = stop({ entries: [tn], reply: `EVIDENCE report \\| \"${rep}\"` }); | \n|  | check('task-notification result is report', !blocked(c), c.out); | \n|  | const err = 'File does not exist at that path now'; | \n|  | const d = stop({ entries: [use('m', 'Read', { file_path: '/r/x' }, at(1)), res('m', err, at(2), { isError: true })], reply: `EVIDENCE tool \\| \"${err}\"` }); | \n|  | check('error result of a non-Bash tool is tool', !blocked(d), d.out); | \n|  | const e = stop({ entries: [use('m', 'Bash', { command: 'ls x' }, at(1)), res('m', err, at(2), { isError: true })], reply: `EVIDENCE tool \\| \"${err}\"` }); | \n|  | check('error result of Bash is not tool', blocked(e) && reason(e).includes('layer none'), e.out); | \n|  | const page = 'The hooks reference lists every event'; | \n|  | const f = stop({ entries: [use('w', 'WebFetch', { url: 'https://example.com' }, at(1)), res('w', page, at(2))], reply: `EVIDENCE web \\| \"${page}\"` }); | \n|  | check('WebFetch result is web', !blocked(f), f.out); | \n|  | } | \n|  | { | \n|  | const sid = 'cap'; | \n|  | const outs = []; | \n|  | for (let i = 0; i < 4; i++) outs.push(stop({ sid, noPrompt: i > 0, reply: 'EVIDENCE read \\| \"this passage appears nowhere at all\"' }).out); | \n|  | check('three blocks, then the 4th in one prompt releases', outs.slice(0, 3).every(o => o.includes('\"block\"')) && outs[3] === '', JSON.stringify(outs)); | \n|  | const again = stop({ sid, reply: 'EVIDENCE read \\| \"this passage appears nowhere at all\"' }); | \n|  | check('a new prompt resets the count', blocked(again), again.out); | \n|  | } | \n|  | { | \n|  | const a = stop({ entries: readAt(1), reply: 'Done. The build is green and the file is updated.' }); | \n|  | check('a reply with no EVIDENCE lines passes', a.out === '', a.out); | \n|  | const b = stop({ entries: [], reply: 'UNVERIFIED: the deploy finished, I did not check the log.' }); | \n|  | check('an UNVERIFIED sentence passes', b.out === '', b.out); | \n|  | } | \n|  | { | \n|  | const a = stop({ entries: [...readAt(1), human('one more thing', at(3))], promptAt: at(3), reply: `EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('mid-turn user prompt moves the turn start (state)', blocked(a) && reason(a).includes('found in no tool result of this turn'), a.out); | \n|  | const b = stop({ noPrompt: true, entries: [human('first', at(0)), ...readAt(1), human('one more thing', at(3)), say('ok', at(4))], reply: `EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('mid-turn user prompt moves the turn start (no state, from the transcript)', blocked(b) && reason(b).includes('found in no tool result of this turn'), b.out); | \n|  | const c = stop({ entries: [human('go on', at(0)), ...readAt(1)], promptAt: at(0), reply: `EVIDENCE read \\| \"${RD}\"` }); | \n|  | check('results after the prompt still count', !blocked(c), c.out); | \n|  | } | \n|  | { | \n|  | const late = 'result line that arrives late here'; | \n|  | let calls = 0; | \n|  | const onRetry = f => { calls++; fs.appendFileSync(f, [use('l', 'Read', { file_path: '/r/l' }, at(1)), res('l', late, at(2))].map(x => JSON.stringify(x)).join('\\n') + '\\n'); }; | \n|  | const a = stop({ reply: `EVIDENCE read \\| \"${late}\"`, opts: { retryMs: 5, onRetry } }); | \n|  | check('transcript lag: a passage that lands during the retry passes', !blocked(a) && calls === 1, `${a.out} calls ${calls}`); | \n|  | } | \n|  | { | \n|  | const file = path.join(ROOT, 'e2e.jsonl'); fs.writeFileSync(file, '\\n'); | \n|  | const r = spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'e2e', transcript_path: file, last_assistant_message: 'EVIDENCE read \\| \"nothing anywhere here\"' }), env: { ...process.env, TMPDIR: ROOT }, encoding: 'utf8' }); | \n|  | let j = null; try { j = JSON.parse(r.stdout); } catch { /* none */ } | \n|  | check('stdin entry point prints block JSON and exits 0', r.status === 0 && j && j.decision === 'block' && j.reason.endsWith('Fix each, or start the claim with UNVERIFIED:.'), `${r.status} ${r.stdout}`); | \n|  | const bad = spawnSync(process.execPath, [HOOK], { input: 'not json', encoding: 'utf8' }); | \n|  | check('a crash is a silent pass (exit 0, no output)', bad.status === 0 && bad.stdout === '', `${bad.status} ${bad.stdout}`); | \n|  | } | \n|  |  | \n|  | { // 1: a read with no file mtime to check is stale after a later Bash call that is not an allowlisted read | \n|  | const d = repo(); const folder = fs.mkdtempSync(path.join(ROOT, 'folder-')); | \n|  | const bashAt = (id, cmd, out, k, cwd = d) => [use(id, 'Bash', { command: cmd }, at(k)), res(id, out, at(k + 1), { cwd })]; | \n|  | const gs = 'On branch main, nothing to commit here'; const gl = 'src/b.ts listed by a glob'; const gp = 'notes.md:4: the folder grep hit line'; const mp = 'cat output of a path missing now'; | \n|  | const a = [stop({ cwd: d, entries: [...bashAt('s1', 'git status', gs, 1), ...bashAt('s2', 'touch x', '', 3)], reply: `EVIDENCE read \\| \"${gs}\"` }), | \n|  | stop({ entries: [use('g1', 'Glob', { pattern: '**/*.ts' }, at(1)), res('g1', gl, at(2)), ...bashAt('s3', 'npm run build', 'built', 3)], reply: `EVIDENCE read \\| \"${gl}\"` }), | \n|  | stop({ entries: [use('g2', 'Grep', { pattern: 'hit', path: folder }, at(1)), res('g2', gp, at(2)), ...bashAt('s4', 'mkdir y', '', 3)], reply: `EVIDENCE read \\| \"${gp}\"` }), | \n|  | stop({ entries: [...bashAt('s5', 'cat gone.md', mp, 1, folder), ...bashAt('s6', 'mv gone.md old.md', '', 3, folder)], reply: `EVIDENCE read \\| \"${mp}\"` })]; | \n|  | check('N1 staleness: git status, a Glob, a folder Grep or a Bash read of a missing path, then a non-read Bash call, is stale', | \n|  | a.every(o => blocked(o) && reason(o).includes('stale read')), a.map(o => o.out \\|\\| 'passed').join(' \\| ')); | \n|  | fs.writeFileSync(path.join(folder, 'f'), 'x'); fs.utimesSync(path.join(folder, 'f'), new Date(old(0)), new Date(old(0))); const ls = 'f, notes listed by ls here'; | \n|  | const b = stop({ cwd: folder, entries: [...bashAt('l1', 'ls', ls, 1, folder), ...bashAt('l2', 'cat f', 'x', 3, folder)], reply: `EVIDENCE read \\| \"${ls}\"` }); | \n|  | check('N1 staleness: ls, then cat f (allowlisted reads only), keeps the ls result read', !blocked(b), b.out); | \n|  | } | \n|  | { // 2: spilled tool outputs under <transcript folder>/<session>/tool-results/ can be read | \n|  | const said = 'spilled output: 512 rows, all checked'; const sid = 'spill'; const spill = path.join(TDIR, sid, 'tool-results'); | \n|  | const ok = [['Read', { file_path: path.join(spill, 'b1.txt') }], ['Grep', { pattern: 'rows', path: spill }], ['Bash', { command: `grep -n rows ${path.join(spill, 'b1.txt')}` }]]; | \n|  | const a = ok.map(([name, input]) => stop({ sid, entries: [use('x', name, input, at(1)), res('x', said, at(2))], reply: `EVIDENCE read \\| \"${said}\"` })); | \n|  | check('N2 spill: a Read, Grep or Bash read under <session>/tool-results/ is read', a.every(o => !blocked(o)), a.map(o => o.out).join(' \\| ')); | \n|  | const no = [path.join(TDIR, 'other', 'tool-results', 'x.txt'), path.join(TDIR, sid, 'subagents', 'a.jsonl'), path.join(TDIR, 'memory', 'MEMORY.md'), path.join(TDIR, sid)]; | \n|  | const b = no.map(p => stop({ sid, entries: [use('x', 'Grep', { pattern: 'rows', path: p }, at(1)), res('x', said, at(2))], reply: `EVIDENCE read \\| \"${said}\"` })); | \n|  | check('N2 spill: another session\\'s tool-results, subagents, memory and the session folder stay not read', | \n|  | b.every(o => blocked(o) && reason(o).includes('none (session transcript or its folder)')), b.map((o, i) => blocked(o) ? '' : no[i]).join(' ')); | \n|  | } | \n|  | { // 3: core.fsmonitor true is git's built-in monitor | \n|  | const g = 'abc1234 fix the build script'; | \n|  | const a = stop({ entries: [use('g', 'Bash', { command: 'git log --oneline -3' }, at(1)), res('g', g, at(2), { cwd: repo(['core.fsmonitor', 'true']) })], reply: `EVIDENCE read \\| \"${g}\"` }); | \n|  | check('N3 git config: core.fsmonitor true keeps a git read read', !blocked(a), a.out); | \n|  | } | \n|  | { // 4: line parsing | \n|  | const a = stop({ entries: readAt(1), reply: `EVIDENCE read \\| \"${RD}\"  \\t` }); | \n|  | check('N4 parsing: trailing whitespace after a well-formed line is trimmed', !blocked(a), a.out); | \n|  | const b = stop({ entries: readAt(1), reply: 'EVIDENCE-based checks \\| they matter here.\\nEVIDENCE-based review is the goal.' }); | \n|  | check('N4 parsing: EVIDENCE-based is not an EVIDENCE line', b.out === '', b.out); | \n|  | const c = stop({ entries: readAt(1), reply: 'The form:\\n```\\nEVIDENCE <layer> \\| \"<passage>\"\\nEVIDENCE read \\| \"an example that is in no result\"\\n```\\nDone.' }); | \n|  | check('N4 parsing: lines inside ``` fences are skipped', c.out === '', c.out); | \n|  | } | \n|  | { // 5: a cause for layer none | \n|  | const sid = 'why'; const p = k => `passage number ${k} for the none cause`; const gitDir = repo(['diff.external', 'x']); | \n|  | const entries = [use('a', 'Bash', { command: `echo \"${p(1)}\"` }, at(1)), res('a', p(1), at(2)), | \n|  | use('b', 'Bash', { command: 'cat nofile.md' }, at(3)), res('b', p(2), at(4), { isError: true }), | \n|  | use('c', 'Write', { file_path: '/r/w.md', content: p(3) }, at(5)), res('c', 'File created successfully', at(6)), use('d', 'Bash', { command: 'cat w.md' }, at(7)), res('d', p(3), at(8)), | \n|  | use('e', 'Read', { file_path: path.join(TDIR, `${sid}.jsonl`) }, at(9)), res('e', p(4), at(10)), | \n|  | use('f', 'Agent', { prompt: 'x', run_in_background: true }, at(11)), res('f', p(5), at(12), { toolUseResult: { status: 'async_launched' } }), | \n|  | use('g', 'Bash', { command: 'git log --oneline -3' }, at(13)), res('g', p(6), at(14), { cwd: gitDir })]; | \n|  | const a = stop({ sid, entries, reply: [1, 2, 3, 4, 5, 6].map(k => `EVIDENCE read \\| \"${p(k)}\"`).join('\\n') }); | \n|  | const why = ['Bash command not on the read allowlist', 'Bash error', 'path written earlier this turn', 'session transcript or its folder', 'background agent launch notice', 'git config prints chosen text']; | \n|  | check('N5 cause: the block message names why each record has layer none', blocked(a) && why.every((w, i) => reason(a).includes(`none (${w}): EVIDENCE read \\| \"${p(i + 1)}\"`)), a.out); | \n|  | } | \n|  | { // 6: inside \"...\" only $, backtick and backslash refuse | \n|  | const kept = [['grep -n \"TODO.*fix\" f', ['f']], ['cat \"a*b\"', ['a*b']], ['grep \"a;b\\|c&d\" f', ['f']], ['cat \"x (1) {y} #z =w <q>?[].md\"', ['x (1) {y} #z =w <q>?[].md']]]; | \n|  | check('N6 quotes: * ? [ ] ( ) { } ; & \\| < > # = are literal inside double quotes', kept.every(([x, w]) => JSON.stringify(H.bashRead(x)) === JSON.stringify(w)), | \n|  | kept.filter(([x, w]) => JSON.stringify(H.bashRead(x)) !== JSON.stringify(w)).map(([x]) => `${x} -> ${JSON.stringify(H.bashRead(x))}`).join(' \\| ')); | \n|  | const refused = ['grep \"a$b\" f', 'cat \"a`b`\"', 'cat \"a\\\\b\"']; | \n|  | check('N6 quotes: $, backtick and backslash inside double quotes refuse', refused.every(x => H.bashRead(x) === null), refused.filter(x => H.bashRead(x) !== null).join(' \\| ')); | \n|  | } | \n|  | { // 7: a missing transcript; the default wait | \n|  | const r = spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'gone', transcript_path: path.join(ROOT, 'no-such.jsonl'), last_assistant_message: 'EVIDENCE read \\| \"nothing anywhere here\"' }), encoding: 'utf8' }); | \n|  | check('N7 a missing transcript passes silently (exit 0, no output)', r.status === 0 && r.stdout === '' && r.stderr === '', `${r.status} ${r.stdout} ${r.stderr}`); | \n|  | const t0 = Date.now(); const a = stop({ entries: readAt(1), reply: 'EVIDENCE read \\| \"found nowhere, with the default wait\"', opts: { retryMs: undefined } }); const ms = Date.now() - t0; | \n|  | check('N7 with the default wait a passage found nowhere blocks after at least 1.4 s', blocked(a) && ms >= 1400, `${ms} ms ${a.out}`); | \n|  | } | \n|  |  | \n|  | { // 8: lead and fences | \n|  | const fake = 'EVIDENCE read \\| \"a passage that is in no tool result\"'; | \n|  | const r1 = stop({ entries: readAt(1), reply: 'EVIDENCE: read \"the cache is cleared\"' }); | \n|  | check('R1 EVIDENCE: read \"...\" is malformed and blocks', blocked(r1) && reason(r1).includes('malformed: EVIDENCE: read \"the cache is cleared\"'), r1.out); | \n|  | const r2 = stop({ entries: readAt(1), reply: 'EVIDENCE: \"the cache is cleared now\"' }); | \n|  | check('R2 EVIDENCE: \"...\" is malformed', blocked(r2) && reason(r2).includes('malformed: EVIDENCE: \"the cache is cleared now\"'), r2.out); | \n|  | const r3 = stop({ entries: readAt(1), reply: `Run it:\\n\\`\\`\\` npm test\\`\\`\\`\\n${fake}` }); | \n|  | check('R3 a one-line ``` npm test``` is not a fence: a later fake line blocks', blocked(r3) && reason(r3).includes(`found in no tool result of this turn: ${fake}`), r3.out); | \n|  | const r4 = stop({ entries: readAt(1), reply: `Code:\\n\\`\\`\\` js\\nconst x = 1;\\n${fake}` }); | \n|  | check('R4 a fence never closed hides nothing: a later fake line blocks', blocked(r4) && reason(r4).includes(`found in no tool result of this turn: ${fake}`), r4.out); | \n|  | const a1 = stop({ entries: readAt(1), reply: `The form:\\n\\`\\`\\` text\\n${fake}\\nEVIDENCE: read \"an example\"\\n\\`\\`\\`\\nDone.` }); | \n|  | check('A1 lines between a matched opening and closing ``` stay skipped', a1.out === '', a1.out); | \n|  | const a2 = stop({ entries: readAt(1), reply: 'Evidence: the build passed\\nevidence: the cache is cleared' }); | \n|  | check('A2 lowercase prose such as Evidence: the build passed stays ignored', a2.out === '', a2.out); | \n|  | const f = stop({ entries: readAt(1), reply: `~~~\\n${fake}\\n~~~\\n\\`\\`\\`\\` md\\n\\`\\`\\`\\n${fake}\\n\\`\\`\\`\\n\\`\\`\\`\\`` }); | \n|  | check('N8 ~~~ fences and fences of 4 or more backticks are fences (a shorter inner fence does not close)', f.out === '', f.out); | \n|  | } | \n|  |  | \n|  | fs.rmSync(ROOT, { recursive: true, force: true }); | \n|  | console.log(`TOTAL ${passed}/${total}`); | \n|  | process.exit(passed === total ? 0 : 1); |", "url": "https://wpnews.pro/news/evidence-hook-every-claim-with-a-receipt", "canonical_source": "https://gist.github.com/Neoneue/d214cec944aa0275877f87356ce5412d", "published_at": "2026-11-15 01:41:29+00:00", "updated_at": "2026-11-15 02:19:36.285158+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic", "evidence-hook"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/evidence-hook-every-claim-with-a-receipt", "markdown": "https://wpnews.pro/news/evidence-hook-every-claim-with-a-receipt.md", "text": "https://wpnews.pro/news/evidence-hook-every-claim-with-a-receipt.txt", "jsonld": "https://wpnews.pro/news/evidence-hook-every-claim-with-a-receipt.jsonld"}}