verifyhash 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/cli/agent-hook.js +431 -0
- package/docs/ADOPT.md +15 -5
- package/docs/AGENT-HOOK.md +111 -0
- package/docs/ANCHORING.md +43 -22
- package/docs/PUBLISH-VERIFY-VH.md +45 -0
- package/examples/README.md +185 -0
- package/examples/policy.lenient.json +5 -0
- package/examples/policy.strict.json +6 -0
- package/examples/run.js +366 -0
- package/examples/sample-dataset/README.txt +10 -0
- package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
- package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
- package/examples/sample-dataset/data/unlabeled.txt +5 -0
- package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
- package/examples/sample-dataset.hints.json +7 -0
- package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
- package/examples/sample-parcel/data/records.csv +4 -0
- package/examples/sample-parcel/delivery-note.txt +9 -0
- package/package.json +26 -3
- package/verifier/README.md +584 -0
- package/verifier/action/README.md +87 -0
- package/verifier/action/action.yml +146 -0
- package/verifier/build-standalone-html.js +1287 -0
- package/verifier/build-standalone.js +989 -0
- package/verifier/ci/journal.generic.sh +96 -0
- package/verifier/ci/journal.github-actions.yml +99 -0
- package/verifier/ci/reproduce-vh.generic.sh +59 -0
- package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
- package/verifier/ci/verify-service.generic.sh +96 -0
- package/verifier/ci/verify-service.github-actions.yml +88 -0
- package/verifier/ci/verify-vh.generic.sh +75 -0
- package/verifier/ci/verify-vh.github-actions.yml +56 -0
- package/verifier/dist/BUILD-PROVENANCE.json +210 -0
- package/verifier/dist/seal-vh-standalone.js +876 -0
- package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.html +3373 -0
- package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.js +5123 -0
- package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
- package/verifier/lib/canonical.js +141 -0
- package/verifier/lib/keccak.js +30 -0
- package/verifier/lib/keccak256-vendored.js +206 -0
- package/verifier/lib/merkle.js +145 -0
- package/verifier/lib/revocation-core.js +606 -0
- package/verifier/lib/revocation.js +200 -0
- package/verifier/lib/seal-cli.js +374 -0
- package/verifier/lib/seal-evidence.js +237 -0
- package/verifier/lib/secp256k1-recover.js +249 -0
- package/verifier/package.json +39 -0
- package/verifier/verify-vh.js +3376 -0
- package/docs/ADOPTION.json +0 -11
- package/docs/AUDIT.md +0 -55
- package/docs/DECIDE.md +0 -47
- package/docs/DECISIONS-PENDING.md +0 -27
- package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
- package/docs/ENGINE-LEDGER.json +0 -12
- package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
- package/docs/LOOP-HARDENING-PLAN.md +0 -44
- package/docs/METRICS.jsonl +0 -31
- package/docs/MORNING.md +0 -204
- package/docs/STRATEGY-ARCHIVE.md +0 -5055
- package/docs/SUPERVISOR-RUNBOOK.md +0 -52
- package/docs/USAGE-BUDGET.json +0 -121
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ no pause, no upgrade path, and it never holds funds. Each content hash can be an
|
|
|
12
12
|
**Live deployment — Polygon mainnet (chain id 137):**
|
|
13
13
|
[`0x77d8eF881D5aeEda64788968D13f9146fE1A609B`](https://polygonscan.com/address/0x77d8eF881D5aeEda64788968D13f9146fE1A609B)
|
|
14
14
|
(deployed 2026-07-03; ownerless — the deploying key holds no special power over it). Pin this address
|
|
15
|
-
out-of-band; `vh` reads/writes against it with `--
|
|
15
|
+
out-of-band; `vh` reads/writes against it with `--contract 0x77d8eF881D5aeEda64788968D13f9146fE1A609B`.
|
|
16
16
|
|
|
17
17
|
> **Ready to charge for it?** [`docs/GO-LIVE.md`](docs/GO-LIVE.md) is the decision-ready "first dollar" page (`npm run go-live`): the **self-serve evidence license** is the recommended default; the design-partner **pilot** is the enterprise fallback.
|
|
18
18
|
|
|
@@ -20,13 +20,15 @@ out-of-band; `vh` reads/writes against it with `--registry 0x77d8eF881D5aeEda647
|
|
|
20
20
|
|
|
21
21
|
> **Adopt in one line.** Want to *receive* and *check* sealed artifacts without an account or our toolchain?
|
|
22
22
|
> The self-serve on-ramp is [`docs/ADOPT.md`](docs/ADOPT.md): `npx --yes verify-vh demo` for a 5-second
|
|
23
|
-
> proof, or a one-line GitHub Action
|
|
23
|
+
> proof, or a one-line GitHub Action
|
|
24
|
+
> (`uses: verifyhash/verifyhash/verifier/action@17696eff5d910b496b8935052ff42ee2e7c6a85a` — pinned to a
|
|
25
|
+
> real commit on `main`; re-pin to a full commit SHA **you** trust) to gate your CI — and
|
|
24
26
|
> the free→paid bridge to issuing **your own** signed, customer-verifiable seals (the paid producer surface).
|
|
25
27
|
|
|
26
28
|
`verifyhash` ships the `vh` command. You do **not** need to clone the repo to use it.
|
|
27
29
|
|
|
28
30
|
```bash
|
|
29
|
-
#
|
|
31
|
+
# published on npm — https://www.npmjs.com/package/verifyhash
|
|
30
32
|
npm install -g verifyhash # puts `vh` on your PATH
|
|
31
33
|
# or run it without installing:
|
|
32
34
|
npx verifyhash --help
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// cli/agent-hook.js — `vh-agent-hook` (T-73.4): ZERO-CONFIG SessionEnd transcript sealing for
|
|
5
|
+
// Claude Code, over the shipped FREE `vh agent seal` path.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS IS
|
|
8
|
+
// A Claude Code `SessionEnd` hook. The host writes ONE JSON hook event to stdin
|
|
9
|
+
// ({ transcript_path, session_id, cwd, ... }); this bin maps the session's transcript JSONL into
|
|
10
|
+
// the canonical agent-session event schema (the examples/agent-session/map-transcript.js
|
|
11
|
+
// approach — a tiny mapping between the MAPPING BEGIN/END markers below), seals it UNSIGNED via
|
|
12
|
+
// the SHIPPED packet builder (cli/agent.js buildPacket/serializePacket — the exact free
|
|
13
|
+
// `vh agent seal` path over cli/core/agent-session.js; NO re-implemented crypto), and writes
|
|
14
|
+
// <outDir>/<session_id>.vhagent.json
|
|
15
|
+
// where <outDir> is $VH_HOOK_OUT (resolved against the event's cwd) or `.vh-sessions/` under the
|
|
16
|
+
// event's cwd by default. The `vh agent verify` one-liner is printed on stderr. That converts the
|
|
17
|
+
// agent-evidence lane's ~20-line adoption cost to ~0: install the package, register the hook, done.
|
|
18
|
+
//
|
|
19
|
+
// POSTURE (all load-bearing):
|
|
20
|
+
// * FREE tier only: UNSIGNED seal — no key, no license, no network, ever.
|
|
21
|
+
// * The hook's own code is Node-core only (fs/path) + the shipped seal modules; no new dependency.
|
|
22
|
+
// * DRIFT-TOLERANT mapping: an unknown/extra JSONL line kind, an unknown content-block kind, or a
|
|
23
|
+
// malformed (e.g. crash-truncated) line is SKIPPED-AND-COUNTED, never fatal — a host upgrade
|
|
24
|
+
// must not silently kill sealing.
|
|
25
|
+
// * NAMED exits, and a top-level catch so this bin can NEVER crash the host's session end:
|
|
26
|
+
// 0 OK sealed + written
|
|
27
|
+
// 2 BAD_HOOK_EVENT malformed stdin (not JSON / not an object / bad session_id)
|
|
28
|
+
// 3 TRANSCRIPT_UNREADABLE transcript_path missing from the event, or unreadable/oversized
|
|
29
|
+
// 4 EMPTY_TRANSCRIPT the transcript exists but yields ZERO mappable events
|
|
30
|
+
// 5 SEAL_FAILED the shipped core refused the mapped events (named reason relayed)
|
|
31
|
+
// 6 WRITE_FAILED cannot create <outDir> or write the packet
|
|
32
|
+
// 7 INTERNAL the top-level catch (a bug — named, never an unhandled throw)
|
|
33
|
+
// Every failure writes NOTHING (the packet is written last, in one shot).
|
|
34
|
+
// * Deterministic: the same transcript + session_id re-seals to BYTE-IDENTICAL packet bytes, so a
|
|
35
|
+
// repeat run for the same session_id deterministically overwrites.
|
|
36
|
+
//
|
|
37
|
+
// TRUST BOUNDARY (pinned VERBATIM here, in docs/AGENT-HOOK.md, and in the test):
|
|
38
|
+
// The seal proves the log is INTACT since seal, NOT that the agent behaved well. NOT a trusted
|
|
39
|
+
// timestamp — ts fields are self-asserted. And payloads embed VERBATIM — redact before sharing.
|
|
40
|
+
|
|
41
|
+
const fs = require("fs");
|
|
42
|
+
const path = require("path");
|
|
43
|
+
|
|
44
|
+
// The SHIPPED free unsigned seal (cli/agent.js is the `vh agent seal` surface over the pure
|
|
45
|
+
// cli/core/agent-session.js core). Reused VERBATIM — this file contains zero crypto.
|
|
46
|
+
const { buildPacket, serializePacket, MAX_INPUT_BYTES } = require("./agent");
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------------------------------
|
|
49
|
+
// The NAMED exit contract (this bin's own — documented in docs/AGENT-HOOK.md).
|
|
50
|
+
// ---------------------------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const EXIT = Object.freeze({
|
|
53
|
+
OK: 0,
|
|
54
|
+
BAD_HOOK_EVENT: 2,
|
|
55
|
+
TRANSCRIPT_UNREADABLE: 3,
|
|
56
|
+
EMPTY_TRANSCRIPT: 4,
|
|
57
|
+
SEAL_FAILED: 5,
|
|
58
|
+
WRITE_FAILED: 6,
|
|
59
|
+
INTERNAL: 7,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Reverse map for the named stderr prefix (`vh-agent-hook: TRANSCRIPT_UNREADABLE: ...`).
|
|
63
|
+
const EXIT_NAME = Object.freeze(
|
|
64
|
+
Object.fromEntries(Object.entries(EXIT).map(([name, code]) => [code, name]))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// The PINNED boundary lines — docs/AGENT-HOOK.md and the test carry these VERBATIM (anti-drift:
|
|
68
|
+
// the doc quotes the exact wording the code prints).
|
|
69
|
+
const BOUNDARY_INTACT_LINE =
|
|
70
|
+
"The seal proves the log is INTACT since seal, NOT that the agent behaved well.";
|
|
71
|
+
const BOUNDARY_TIMESTAMP_LINE = "NOT a trusted timestamp — ts fields are self-asserted.";
|
|
72
|
+
const BOUNDARY_REDACT_LINE =
|
|
73
|
+
"Payloads embed VERBATIM (prompts, code, tool output): run `vh agent redact` before sharing a packet.";
|
|
74
|
+
|
|
75
|
+
// Cap on the stdin hook event — a hook event is a few hundred bytes; a runaway stream is hostile.
|
|
76
|
+
const MAX_STDIN_BYTES = 1024 * 1024; // 1 MiB
|
|
77
|
+
|
|
78
|
+
// Stdin idle timeout: the host pipes the hook event instantly, so if nothing arrives we must fail
|
|
79
|
+
// with a hint rather than hang the host's session end. $VH_HOOK_STDIN_TIMEOUT_MS overrides; 0 disables.
|
|
80
|
+
const DEFAULT_STDIN_TIMEOUT_MS = 60000; // 60s — generous vs the host's own hook-timeout
|
|
81
|
+
|
|
82
|
+
// The default out directory, under the hook event's cwd (overridable via $VH_HOOK_OUT).
|
|
83
|
+
const DEFAULT_OUT_DIR = ".vh-sessions";
|
|
84
|
+
|
|
85
|
+
// A packet embeds prompts / code / tool output VERBATIM, so the zero-config default dir SELF-IGNORES:
|
|
86
|
+
// the first seal drops this `.gitignore` (an all-globbing `*`, which git also applies to the file
|
|
87
|
+
// itself) inside it, so a routine `git add -A` / `git commit` — or a public repo — can never silently
|
|
88
|
+
// commit a secret-bearing packet. Best-effort and only for the default dir; a custom VH_HOOK_OUT is
|
|
89
|
+
// the operator's to manage (see docs/AGENT-HOOK.md).
|
|
90
|
+
const OUT_DIR_GITIGNORE =
|
|
91
|
+
"# Written by vh-agent-hook. These *.vhagent.json packets embed prompts, code, and tool output\n" +
|
|
92
|
+
"# VERBATIM — do NOT commit them (run `vh agent redact` before sharing). This ignores the whole dir.\n" +
|
|
93
|
+
"*\n";
|
|
94
|
+
|
|
95
|
+
// Printed by `--help` / `-h` (and, verbatim-ish, when stdin is an interactive terminal): this is a
|
|
96
|
+
// HOOK, not an interactive command, so an operator testing the install gets guidance, not a hang.
|
|
97
|
+
const USAGE = [
|
|
98
|
+
"vh-agent-hook — zero-config Claude Code SessionEnd transcript sealing (FREE, unsigned).",
|
|
99
|
+
"",
|
|
100
|
+
"This is a Claude Code SessionEnd HOOK, not an interactive command. The host pipes ONE hook-event",
|
|
101
|
+
"JSON ({ transcript_path, session_id, cwd }) to stdin; this bin seals the transcript into",
|
|
102
|
+
" <outDir>/<session_id>.vhagent.json (outDir = $VH_HOOK_OUT, else .vh-sessions/ under the event cwd)",
|
|
103
|
+
"and prints the `vh agent verify` one-liner on stderr. See docs/AGENT-HOOK.md for the 3-line install.",
|
|
104
|
+
"",
|
|
105
|
+
"Env:",
|
|
106
|
+
" VH_HOOK_OUT=<dir> override the out dir (a relative value resolves against the event cwd)",
|
|
107
|
+
" VH_HOOK_STDIN_TIMEOUT_MS=<n> stdin idle timeout in ms (default 60000; 0 disables)",
|
|
108
|
+
"",
|
|
109
|
+
"Testing the install by hand? It waits for a hook event on stdin — pipe one in, e.g.:",
|
|
110
|
+
" printf '{\"session_id\":\"s1\",\"transcript_path\":\"./t.jsonl\",\"cwd\":\".\"}' | vh-agent-hook",
|
|
111
|
+
].join("\n");
|
|
112
|
+
|
|
113
|
+
// session_id becomes a FILENAME component: strict allowlist (Claude Code ids are UUIDs), no leading
|
|
114
|
+
// dot, no path separators — a traversal-shaped id is a BAD_HOOK_EVENT, never a write outside outDir.
|
|
115
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
116
|
+
|
|
117
|
+
function isPlainObject(v) {
|
|
118
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------------------------------
|
|
122
|
+
// MAPPING BEGIN — ONE Claude Code transcript JSONL line -> canonical `vh agent` events.
|
|
123
|
+
// Claude Code shapes (v1.x): message lines are { type: "user"|"assistant", message: { role,
|
|
124
|
+
// content }, timestamp, ... } where content is a string or an array of blocks ({ type: "text" },
|
|
125
|
+
// { type: "tool_use", id, name, input }, { type: "tool_result", tool_use_id, content, is_error? }).
|
|
126
|
+
// Everything else (summary / system / file-history-snapshot / future kinds) is skipped-and-counted.
|
|
127
|
+
// ---------------------------------------------------------------------------------------------------
|
|
128
|
+
function mapLine(line, skipped) {
|
|
129
|
+
if (!isPlainObject(line) || (line.type !== "user" && line.type !== "assistant") || !isPlainObject(line.message)) {
|
|
130
|
+
skipped.lines++; // non-message or unknown line kind — tolerated drift, never fatal
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const ts = typeof line.timestamp === "string" ? line.timestamp : ""; // self-asserted; may be absent
|
|
134
|
+
const actor = line.type === "user" ? "user" : "agent:assistant";
|
|
135
|
+
const textType = line.type === "user" ? "prompt" : "completion";
|
|
136
|
+
const content = line.message.content;
|
|
137
|
+
if (typeof content === "string") return [{ ts, actor, type: textType, payload: content }];
|
|
138
|
+
if (!Array.isArray(content)) {
|
|
139
|
+
skipped.lines++; // a message whose content shape we do not know — skip the line, count it
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const b of content) {
|
|
144
|
+
if (isPlainObject(b) && b.type === "text" && typeof b.text === "string") {
|
|
145
|
+
out.push({ ts, actor, type: textType, payload: b.text });
|
|
146
|
+
} else if (isPlainObject(b) && b.type === "tool_use" && line.type === "assistant") {
|
|
147
|
+
out.push({ ts, actor, type: "tool_call",
|
|
148
|
+
payload: JSON.stringify({ id: b.id, name: b.name, input: b.input === undefined ? null : b.input }) });
|
|
149
|
+
} else if (isPlainObject(b) && b.type === "tool_result" && line.type === "user") {
|
|
150
|
+
const id = typeof b.tool_use_id === "string" ? b.tool_use_id : "unknown";
|
|
151
|
+
const e = { ts, actor: "tool:" + id, type: "tool_result",
|
|
152
|
+
payload: typeof b.content === "string" ? b.content : JSON.stringify(b.content === undefined ? null : b.content),
|
|
153
|
+
meta: b.is_error === true ? { tool_use_id: id, is_error: true } : { tool_use_id: id } };
|
|
154
|
+
out.push(e);
|
|
155
|
+
} else {
|
|
156
|
+
skipped.blocks++; // unknown/extra content-block kind (thinking, image, future) — counted
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
// MAPPING END
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map a whole Claude Code transcript JSONL text into the canonical, seq-contiguous event array.
|
|
165
|
+
* PURE and TOTAL: a malformed line (e.g. crash-truncated tail) is skipped-and-counted, never a
|
|
166
|
+
* throw — the hook must survive host drift.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} text the raw transcript JSONL.
|
|
169
|
+
* @returns {{ events: object[], skipped: { lines: number, blocks: number, malformed: number } }}
|
|
170
|
+
*/
|
|
171
|
+
function mapTranscriptText(text) {
|
|
172
|
+
const skipped = { lines: 0, blocks: 0, malformed: 0 };
|
|
173
|
+
const events = [];
|
|
174
|
+
const lines = String(text).split(/\r?\n/);
|
|
175
|
+
for (const raw of lines) {
|
|
176
|
+
if (raw.trim() === "") continue;
|
|
177
|
+
let line;
|
|
178
|
+
try {
|
|
179
|
+
line = JSON.parse(raw);
|
|
180
|
+
} catch (_) {
|
|
181
|
+
skipped.malformed++; // tolerated: a truncated/garbled line never kills the seal
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const ev of mapLine(line, skipped)) events.push({ seq: events.length, ...ev });
|
|
185
|
+
}
|
|
186
|
+
return { events, skipped };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------------------------------
|
|
190
|
+
// I/O plumbing.
|
|
191
|
+
// ---------------------------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Read all of stdin (size-capped, idle-timeout-guarded). Resolves the UTF-8 text; rejects on a
|
|
195
|
+
* stream error, on exceeding the cap, or if `timeoutMs > 0` and no data arrives within that idle
|
|
196
|
+
* window — so a misconfigured/never-closing stdin fails with a hint instead of hanging the host.
|
|
197
|
+
*/
|
|
198
|
+
function readStdin(stream, timeoutMs) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const chunks = [];
|
|
201
|
+
let size = 0;
|
|
202
|
+
let timer = null;
|
|
203
|
+
const clear = () => {
|
|
204
|
+
if (timer) clearTimeout(timer);
|
|
205
|
+
timer = null;
|
|
206
|
+
};
|
|
207
|
+
const arm = () => {
|
|
208
|
+
if (!timeoutMs || timeoutMs <= 0) return;
|
|
209
|
+
clear();
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
reject(
|
|
212
|
+
new Error(
|
|
213
|
+
`no hook event on stdin within ${timeoutMs}ms — this bin reads the SessionEnd hook-event ` +
|
|
214
|
+
"JSON from stdin (run `vh-agent-hook --help`)"
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
stream.destroy();
|
|
218
|
+
}, timeoutMs);
|
|
219
|
+
if (timer.unref) timer.unref(); // never keep the process alive just for this timer
|
|
220
|
+
};
|
|
221
|
+
stream.on("data", (chunk) => {
|
|
222
|
+
size += chunk.length;
|
|
223
|
+
if (size > MAX_STDIN_BYTES) {
|
|
224
|
+
clear();
|
|
225
|
+
reject(new Error(`stdin exceeds the ${MAX_STDIN_BYTES}-byte hook-event cap`));
|
|
226
|
+
stream.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
arm(); // idle window resets on every chunk
|
|
231
|
+
});
|
|
232
|
+
stream.on("end", () => {
|
|
233
|
+
clear();
|
|
234
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
235
|
+
});
|
|
236
|
+
stream.on("error", (e) => {
|
|
237
|
+
clear();
|
|
238
|
+
reject(e);
|
|
239
|
+
});
|
|
240
|
+
arm();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The stdin idle timeout in ms from $VH_HOOK_STDIN_TIMEOUT_MS (default 60000; 0 disables). */
|
|
245
|
+
function stdinTimeoutMs(env) {
|
|
246
|
+
const raw = env.VH_HOOK_STDIN_TIMEOUT_MS;
|
|
247
|
+
if (raw === undefined || raw === "") return DEFAULT_STDIN_TIMEOUT_MS;
|
|
248
|
+
const n = Number(raw);
|
|
249
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_STDIN_TIMEOUT_MS;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Best-effort: drop the self-ignoring `.gitignore` into the DEFAULT out dir so a routine `git add -A`
|
|
254
|
+
* can never commit a secret-bearing packet. Never fails the seal; never clobbers an operator's own
|
|
255
|
+
* `.gitignore`. Called ONLY after a successful packet write, and only for the default dir.
|
|
256
|
+
*/
|
|
257
|
+
function writeSelfIgnore(outDir, writeErr) {
|
|
258
|
+
const giPath = path.join(outDir, ".gitignore");
|
|
259
|
+
try {
|
|
260
|
+
if (!fs.existsSync(giPath)) fs.writeFileSync(giPath, OUT_DIR_GITIGNORE);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
writeErr(
|
|
263
|
+
`vh-agent-hook: note: could not self-ignore ${giPath} (${e.message}) — add '${DEFAULT_OUT_DIR}/' to .gitignore yourself\n`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function fail(writeErr, code, message) {
|
|
269
|
+
writeErr(`vh-agent-hook: ${EXIT_NAME[code]}: ${message}\n`);
|
|
270
|
+
return code;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The whole hook, I/O injected for tests. NEVER throws (its own catch backs up the bin-level one).
|
|
275
|
+
*
|
|
276
|
+
* @param {object} io { stdinText?, argv?, stdinIsTTY?, env?, writeErr? }
|
|
277
|
+
* @returns {Promise<number>} a named EXIT code.
|
|
278
|
+
*/
|
|
279
|
+
async function runHook(io = {}) {
|
|
280
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
281
|
+
try {
|
|
282
|
+
const env = io.env || process.env;
|
|
283
|
+
const argv = io.argv || process.argv.slice(2);
|
|
284
|
+
|
|
285
|
+
// `--help`/`-h`: this is a HOOK, not an interactive command. Print usage on STDERR (stdout stays
|
|
286
|
+
// clean, always) and exit OK, so an operator poking at the install gets guidance, not a hang.
|
|
287
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
288
|
+
writeErr(USAGE + "\n");
|
|
289
|
+
return EXIT.OK;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// (1) The hook event from stdin — malformed stdin is BAD_HOOK_EVENT, nothing written.
|
|
293
|
+
let stdinText;
|
|
294
|
+
if (io.stdinText !== undefined) {
|
|
295
|
+
stdinText = io.stdinText;
|
|
296
|
+
} else {
|
|
297
|
+
// Run by hand in a terminal (stdin is a TTY — no event piped)? Don't hang the operator; tell them.
|
|
298
|
+
const isTTY = io.stdinIsTTY !== undefined ? io.stdinIsTTY : process.stdin.isTTY;
|
|
299
|
+
if (isTTY) {
|
|
300
|
+
return fail(
|
|
301
|
+
writeErr,
|
|
302
|
+
EXIT.BAD_HOOK_EVENT,
|
|
303
|
+
"no hook event on stdin (stdin is a terminal). This is a Claude Code SessionEnd hook; it reads " +
|
|
304
|
+
"the hook-event JSON from stdin — run `vh-agent-hook --help`."
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
stdinText = await readStdin(process.stdin, stdinTimeoutMs(env));
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return fail(writeErr, EXIT.BAD_HOOK_EVENT, `cannot read the hook event from stdin: ${e.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
let event;
|
|
314
|
+
try {
|
|
315
|
+
event = JSON.parse(stdinText);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return fail(writeErr, EXIT.BAD_HOOK_EVENT, `stdin is not valid hook-event JSON: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
if (!isPlainObject(event)) {
|
|
320
|
+
return fail(writeErr, EXIT.BAD_HOOK_EVENT, "the hook event must be a JSON object ({ transcript_path, session_id, cwd })");
|
|
321
|
+
}
|
|
322
|
+
const sessionId = event.session_id;
|
|
323
|
+
if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) {
|
|
324
|
+
return fail(
|
|
325
|
+
writeErr,
|
|
326
|
+
EXIT.BAD_HOOK_EVENT,
|
|
327
|
+
`the hook event needs a filename-safe session_id (letters/digits/._- , no leading dot), got: ${JSON.stringify(sessionId)}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// (2) The transcript — a missing field, or an unreadable/oversized file, is TRANSCRIPT_UNREADABLE.
|
|
332
|
+
if (typeof event.transcript_path !== "string" || event.transcript_path.length === 0) {
|
|
333
|
+
return fail(writeErr, EXIT.TRANSCRIPT_UNREADABLE, "the hook event carries no transcript_path");
|
|
334
|
+
}
|
|
335
|
+
const eventCwd = typeof event.cwd === "string" && event.cwd.length > 0 ? event.cwd : process.cwd();
|
|
336
|
+
const transcriptPath = path.resolve(eventCwd, event.transcript_path);
|
|
337
|
+
let transcriptText;
|
|
338
|
+
try {
|
|
339
|
+
const stat = fs.statSync(transcriptPath);
|
|
340
|
+
if (stat.size > MAX_INPUT_BYTES) {
|
|
341
|
+
return fail(
|
|
342
|
+
writeErr,
|
|
343
|
+
EXIT.TRANSCRIPT_UNREADABLE,
|
|
344
|
+
`transcript ${transcriptPath} is OVERSIZED (${stat.size} bytes > the ${MAX_INPUT_BYTES}-byte limit)`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
transcriptText = fs.readFileSync(transcriptPath, "utf8");
|
|
348
|
+
} catch (e) {
|
|
349
|
+
return fail(writeErr, EXIT.TRANSCRIPT_UNREADABLE, `cannot read transcript ${transcriptPath}: ${e.message}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// (3) Map (drift-tolerant: skips are COUNTED, never fatal) — zero events is EMPTY_TRANSCRIPT.
|
|
353
|
+
const { events, skipped } = mapTranscriptText(transcriptText);
|
|
354
|
+
const skippedNote = `skipped ${skipped.lines} non-message line(s), ${skipped.blocks} unmapped block(s), ${skipped.malformed} malformed line(s)`;
|
|
355
|
+
if (events.length === 0) {
|
|
356
|
+
return fail(
|
|
357
|
+
writeErr,
|
|
358
|
+
EXIT.EMPTY_TRANSCRIPT,
|
|
359
|
+
`transcript ${transcriptPath} yields no mappable events (${skippedNote}) — nothing to seal`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// (4) Seal UNSIGNED over the SHIPPED path (free tier: no key, no license, no network).
|
|
364
|
+
const built = buildPacket(events);
|
|
365
|
+
if (!built.ok) {
|
|
366
|
+
const at = built.index !== undefined ? ` at event seq ${built.index}` : "";
|
|
367
|
+
return fail(writeErr, EXIT.SEAL_FAILED, `the shipped seal core refused the mapped events: ${built.reason}${at}`);
|
|
368
|
+
}
|
|
369
|
+
const artifactStr = serializePacket(built.packet);
|
|
370
|
+
|
|
371
|
+
// (5) Write <outDir>/<session_id>.vhagent.json — deterministic bytes, so a repeat run for the
|
|
372
|
+
// same session_id overwrites with the identical packet.
|
|
373
|
+
const usingDefaultOut = !(typeof env.VH_HOOK_OUT === "string" && env.VH_HOOK_OUT.length > 0);
|
|
374
|
+
const outDir = path.resolve(eventCwd, usingDefaultOut ? DEFAULT_OUT_DIR : env.VH_HOOK_OUT);
|
|
375
|
+
const outPath = path.join(outDir, `${sessionId}.vhagent.json`);
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
378
|
+
fs.writeFileSync(outPath, artifactStr);
|
|
379
|
+
} catch (e) {
|
|
380
|
+
return fail(writeErr, EXIT.WRITE_FAILED, `cannot write packet ${outPath}: ${e.message}`);
|
|
381
|
+
}
|
|
382
|
+
// Keep the working tree CLEAN: the zero-config default dir self-ignores so a stray `git add -A`
|
|
383
|
+
// can never commit these VERBATIM-payload packets. Best-effort — a failed self-ignore never fails
|
|
384
|
+
// a good seal, and a custom VH_HOOK_OUT is the operator's to manage (point it outside the tree).
|
|
385
|
+
if (usingDefaultOut) writeSelfIgnore(outDir, writeErr);
|
|
386
|
+
|
|
387
|
+
// (6) The receipt + the verify one-liner + the pinned boundary, all on stderr (a SessionEnd hook
|
|
388
|
+
// should never pollute stdout).
|
|
389
|
+
writeErr(
|
|
390
|
+
`vh-agent-hook: sealed ${events.length} event(s) from ${transcriptPath} (${skippedNote}) -> ${outPath}\n`
|
|
391
|
+
);
|
|
392
|
+
writeErr(`vh-agent-hook: verify with: vh agent verify ${outPath}\n`);
|
|
393
|
+
writeErr(`vh-agent-hook: ${BOUNDARY_INTACT_LINE} ${BOUNDARY_TIMESTAMP_LINE} ${BOUNDARY_REDACT_LINE}\n`);
|
|
394
|
+
return EXIT.OK;
|
|
395
|
+
} catch (e) {
|
|
396
|
+
// The named backstop: a bug in this file must never crash the host's session end.
|
|
397
|
+
return fail(writeErr, EXIT.INTERNAL, `unexpected failure: ${e && e.message ? e.message : String(e)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (require.main === module) {
|
|
402
|
+
// Top-level catch (bin level): whatever happens, exit with a NAMED code — never an unhandled throw.
|
|
403
|
+
runHook().then(
|
|
404
|
+
(code) => process.exit(code),
|
|
405
|
+
(e) => {
|
|
406
|
+
try {
|
|
407
|
+
process.stderr.write(`vh-agent-hook: INTERNAL: ${e && e.message ? e.message : String(e)}\n`);
|
|
408
|
+
} catch (_) {
|
|
409
|
+
/* stderr gone — still exit named */
|
|
410
|
+
}
|
|
411
|
+
process.exit(EXIT.INTERNAL);
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = {
|
|
417
|
+
EXIT,
|
|
418
|
+
EXIT_NAME,
|
|
419
|
+
BOUNDARY_INTACT_LINE,
|
|
420
|
+
BOUNDARY_TIMESTAMP_LINE,
|
|
421
|
+
BOUNDARY_REDACT_LINE,
|
|
422
|
+
DEFAULT_OUT_DIR,
|
|
423
|
+
OUT_DIR_GITIGNORE,
|
|
424
|
+
MAX_STDIN_BYTES,
|
|
425
|
+
DEFAULT_STDIN_TIMEOUT_MS,
|
|
426
|
+
USAGE,
|
|
427
|
+
stdinTimeoutMs,
|
|
428
|
+
mapLine,
|
|
429
|
+
mapTranscriptText,
|
|
430
|
+
runHook,
|
|
431
|
+
};
|
package/docs/ADOPT.md
CHANGED
|
@@ -8,7 +8,7 @@ Pick the row that matches where you are, copy the one line, and run it. The **fr
|
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| **No Node/terminal at all? Verify in your browser** (the 60-second challenge built in) | open [`verifier/dist/verify-vh-standalone.html`](../verifier/dist/verify-vh-standalone.html) | free |
|
|
10
10
|
| **See it work in 5 seconds** (no clone, no flags, no key) | `npx --yes verify-vh demo` | free |
|
|
11
|
-
| **Gate your CI on tampered/forged seals** (GitHub Actions) | `uses:
|
|
11
|
+
| **Gate your CI on tampered/forged seals** (GitHub Actions) | `uses: verifyhash/verifyhash/verifier/action@17696eff5d910b496b8935052ff42ee2e7c6a85a` | free |
|
|
12
12
|
| **Issue signed, customer-verifiable seals of your own** (the paid producer surface) | `vh evidence seal <dir> --sign --license <f> --vendor 0xYOU` | **paid** |
|
|
13
13
|
|
|
14
14
|
The on-ramp is **deliberately one direction**: the free rows convince you the verdict is real, then the
|
|
@@ -69,7 +69,7 @@ see [`verifier/README.md`](../verifier/README.md).
|
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
72
|
-
## 2. Gate your CI in one line — `uses
|
|
72
|
+
## 2. Gate your CI in one line — the pinned `uses:` gate
|
|
73
73
|
|
|
74
74
|
Drop this workflow at `.github/workflows/verify-vh.yml` in the repo that **receives** sealed verifyhash
|
|
75
75
|
artifacts. Every push / pull request then fails the build the instant any artifact is tampered, forged, or
|
|
@@ -84,14 +84,16 @@ jobs:
|
|
|
84
84
|
runs-on: ubuntu-latest
|
|
85
85
|
steps:
|
|
86
86
|
- uses: actions/checkout@v4
|
|
87
|
-
- uses:
|
|
87
|
+
- uses: verifyhash/verifyhash/verifier/action@17696eff5d910b496b8935052ff42ee2e7c6a85a
|
|
88
88
|
with:
|
|
89
89
|
vendor: "0xYOUR_PRODUCER_SIGNER_ADDRESS" # the key that must have signed (omit to check tamper only)
|
|
90
90
|
manifest: "release.manifest" # OR set `artifacts:` instead
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
SHA
|
|
93
|
+
The `uses:` line is pre-pinned to this repository's real slug (`verifyhash/verifyhash`) and a full 40-hex
|
|
94
|
+
commit SHA reachable from `main`, so it works exactly as pasted. Supply-chain hygiene: **re-pin `@<sha>` to
|
|
95
|
+
a commit SHA you have audited and trust** — keep the full-SHA form (a mutable ref like `@main` can change
|
|
96
|
+
under you). The composite action installs **only** the standalone verifier (`js-sha3`) and
|
|
95
97
|
resolves its bundled `verifier/` tree via `${{ github.action_path }}` at run time — so you do **not** vendor
|
|
96
98
|
`verifier/` into your repo. The action lives at [`verifier/action/`](../verifier/action/action.yml); its full
|
|
97
99
|
input table and exit-code contract are in [`verifier/action/README.md`](../verifier/action/README.md).
|
|
@@ -178,6 +180,14 @@ time T") is a separate, human-gated step (see
|
|
|
178
180
|
**zero** install: email ONE file ([`trustledger/dist/trustledger-standalone.html`](../trustledger/dist/trustledger-standalone.html)),
|
|
179
181
|
the partner double-clicks it, drags their real exports in, and the page makes **no network request** (free tier only).
|
|
180
182
|
|
|
183
|
+
## Anchor a sealed artifact on-chain — and verify the receipt offline (free)
|
|
184
|
+
|
|
185
|
+
Both anchoring verbs are free and gate-less: `vh anchor-artifact` writes a sealed artifact's digest into a
|
|
186
|
+
ContributionRegistry deployment (your own RPC, your own key — the only cost is your own gas), and
|
|
187
|
+
`vh verify-anchored` re-proves the receipt fully offline, needing no key at all. Honest boundary up front: a
|
|
188
|
+
receipt from a LOCAL dev chain proves MECHANISM only — what an anchored receipt does and does not prove, the
|
|
189
|
+
worked commands, and the committed fixtures you can verify right now with zero setup live in
|
|
190
|
+
[`ANCHORING.md`](ANCHORING.md) and [`examples/anchoring/`](../examples/anchoring/).
|
|
181
191
|
|
|
182
192
|
---
|
|
183
193
|
<sub>© 2026 verifyhash.com · Licensed under Apache-2.0 (SPDX-License-Identifier: Apache-2.0) — see the [LICENSE](https://verifyhash.com/LICENSE) and [NOTICE](https://verifyhash.com/NOTICE) served with this file.</sub>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# `vh-agent-hook` — zero-config session sealing for Claude Code
|
|
2
|
+
|
|
3
|
+
`vh-agent-hook` is a [Claude Code `SessionEnd` hook](https://docs.anthropic.com/en/docs/claude-code/hooks):
|
|
4
|
+
when a session ends, the host pipes one JSON hook event (`{ transcript_path, session_id, cwd, ... }`)
|
|
5
|
+
to the hook's stdin, and `vh-agent-hook` maps the session's transcript JSONL into the canonical
|
|
6
|
+
agent-session event schema and seals it — **UNSIGNED, on the FREE tier: no key, no license, no
|
|
7
|
+
network** — into one tamper-evident packet:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
<outDir>/<session_id>.vhagent.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`<outDir>` defaults to **`.vh-sessions/`** under the hook event's `cwd`; set the environment
|
|
14
|
+
variable **`VH_HOOK_OUT`** to choose another directory (a relative value resolves against the
|
|
15
|
+
event's `cwd`). The sealing itself is the shipped free `vh agent seal` path over
|
|
16
|
+
`cli/core/agent-session.js` — the hook re-implements no crypto; it is only the transcript mapper
|
|
17
|
+
plus filesystem plumbing. Anyone can then check the packet offline with `vh agent verify` (or the
|
|
18
|
+
zero-install independent verifier — see [AGENTTRACE.md](AGENTTRACE.md)).
|
|
19
|
+
|
|
20
|
+
### Your working tree stays clean automatically
|
|
21
|
+
|
|
22
|
+
A packet embeds your prompts, code, and tool output **verbatim** (see the boundary below), so the
|
|
23
|
+
zero-config default dir is made **self-ignoring**: the first seal drops a `.gitignore` (containing
|
|
24
|
+
`*`) inside `.vh-sessions/`, so a routine `git add -A` / `git commit` — or a public repo, like this
|
|
25
|
+
one — can **never** silently commit a secret-bearing packet, and `git status` stays clean. You don't
|
|
26
|
+
have to add anything to your own `.gitignore`.
|
|
27
|
+
|
|
28
|
+
Prefer to keep packets out of the repo entirely? Point **`VH_HOOK_OUT` at a directory outside your
|
|
29
|
+
working tree** (e.g. `VH_HOOK_OUT=~/.vh-sessions`). If instead you point `VH_HOOK_OUT` at a path
|
|
30
|
+
**inside** the tree, add that directory to your `.gitignore` yourself — the automatic self-ignore is
|
|
31
|
+
written only for the default `.vh-sessions/`. Packets accumulate one-per-session with no rotation, so
|
|
32
|
+
prune the directory when you no longer need the older sealed sessions.
|
|
33
|
+
|
|
34
|
+
## The 3-line install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g verifyhash # 1. installs the `vh` and `vh-agent-hook` bins
|
|
38
|
+
echo '{"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"vh-agent-hook"}]}]}}' > .claude/settings.json # 2. register the hook (merge the "hooks" key if the file already exists)
|
|
39
|
+
vh agent verify .vh-sessions/<session_id>.vhagent.json # 3. after any session ends: verify the sealed packet
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That is the entire integration: every session end now writes one verifiable
|
|
43
|
+
`.vh-sessions/<session_id>.vhagent.json`, and the hook prints the exact `vh agent verify` one-liner
|
|
44
|
+
on stderr each time it seals.
|
|
45
|
+
|
|
46
|
+
## The honest boundary (pinned — read before you rely on a packet)
|
|
47
|
+
|
|
48
|
+
- **The seal proves the log is INTACT since seal, NOT that the agent behaved well.** Garbage-in is
|
|
49
|
+
out of scope: the packet's Merkle head proves the transcript events are unaltered since sealing
|
|
50
|
+
and any disclosed event is verbatim as recorded — it does not prove the transcript faithfully
|
|
51
|
+
records what the agent actually did, and it is not a claim the work was correct or safe.
|
|
52
|
+
- **NOT a trusted timestamp — ts fields are self-asserted.** Event `ts` values are carried verbatim
|
|
53
|
+
from the transcript and are never verified against any clock; "sealed at time T" needs the
|
|
54
|
+
human-owned signing/timestamp trust root (see [TRUST-BOUNDARIES.md](TRUST-BOUNDARIES.md)).
|
|
55
|
+
- **Payloads embed VERBATIM (prompts, code, tool output): run `vh agent redact` before sharing a
|
|
56
|
+
packet.** A sealed packet contains your full prompts, file contents, and tool output. Redaction
|
|
57
|
+
withholds any payload behind its hash commitment WITHOUT changing a single leaf or the root, so
|
|
58
|
+
the redacted copy still verifies: `vh agent redact <packet> --seq <list> --out <redacted>`.
|
|
59
|
+
|
|
60
|
+
## What the hook writes, and when it refuses
|
|
61
|
+
|
|
62
|
+
The packet is written **last, in one shot** — every failure path writes NOTHING. The mapping is
|
|
63
|
+
**drift-tolerant**: unknown/extra transcript line kinds (`summary`, `file-history-snapshot`, future
|
|
64
|
+
kinds), unknown content-block kinds, and malformed (e.g. crash-truncated) lines are
|
|
65
|
+
**skipped-and-counted** on stderr, never fatal. Sealing is **deterministic**: re-running the hook
|
|
66
|
+
for the same `session_id` and transcript overwrites the packet with byte-identical content.
|
|
67
|
+
|
|
68
|
+
Named exit codes (the hook has a top-level catch, so it can never crash the host's session end):
|
|
69
|
+
|
|
70
|
+
| exit | name | meaning |
|
|
71
|
+
| ---- | ----------------------- | ---------------------------------------------------------------- |
|
|
72
|
+
| 0 | `OK` | sealed and written; verify one-liner printed on stderr |
|
|
73
|
+
| 2 | `BAD_HOOK_EVENT` | stdin is not valid hook-event JSON, or `session_id` is missing/not filename-safe |
|
|
74
|
+
| 3 | `TRANSCRIPT_UNREADABLE` | the event carries no `transcript_path`, or the file is missing/unreadable/oversized |
|
|
75
|
+
| 4 | `EMPTY_TRANSCRIPT` | the transcript yields zero mappable events — nothing to seal |
|
|
76
|
+
| 5 | `SEAL_FAILED` | the shipped seal core refused the mapped events (reason relayed) |
|
|
77
|
+
| 6 | `WRITE_FAILED` | cannot create the out directory or write the packet |
|
|
78
|
+
| 7 | `INTERNAL` | the top-level catch — a bug, reported by name, never a crash |
|
|
79
|
+
|
|
80
|
+
(Exit `1` is deliberately reserved for a generic Node crash and is never used by the hook.)
|
|
81
|
+
|
|
82
|
+
### Testing the install by hand
|
|
83
|
+
|
|
84
|
+
The hook reads its event from **stdin**, so running `vh-agent-hook` in a terminal would otherwise
|
|
85
|
+
just wait. Run **`vh-agent-hook --help`** for usage, or pipe a hook event in yourself:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
printf '{"session_id":"s1","transcript_path":"./session.jsonl","cwd":"."}' | vh-agent-hook
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If stdin is an interactive terminal (no event piped), the hook exits `BAD_HOOK_EVENT` with a hint
|
|
92
|
+
rather than hanging; a never-closing stdin is bounded by an idle timeout
|
|
93
|
+
(**`VH_HOOK_STDIN_TIMEOUT_MS`**, default `60000`; `0` disables).
|
|
94
|
+
|
|
95
|
+
## What lands in the packet
|
|
96
|
+
|
|
97
|
+
Claude Code transcript lines map to canonical events the way
|
|
98
|
+
[`examples/agent-session/map-transcript.js`](../examples/agent-session/map-transcript.js) maps
|
|
99
|
+
OpenAI-style exports (see the committed Claude Code fixture
|
|
100
|
+
[`examples/agent-session/transcript.claude-code.jsonl`](../examples/agent-session/transcript.claude-code.jsonl)):
|
|
101
|
+
|
|
102
|
+
- a `user` message (string content or `text` blocks) → `prompt` events (actor `user`);
|
|
103
|
+
- an `assistant` `text` block → a `completion` event (actor `agent:assistant`);
|
|
104
|
+
- an `assistant` `tool_use` block → a `tool_call` event (payload `{ id, name, input }`);
|
|
105
|
+
- a `user` `tool_result` block → a `tool_result` event (actor `tool:<tool_use_id>`, the
|
|
106
|
+
`tool_use_id` carried in `meta`);
|
|
107
|
+
- everything else is skipped-and-counted.
|
|
108
|
+
|
|
109
|
+
The full packet semantics — redaction, single-event proofs, checkpoints, append-only growth, and
|
|
110
|
+
the paid signed-head surface — are documented in [AGENTTRACE.md](AGENTTRACE.md). This hook stays
|
|
111
|
+
entirely on the free tier.
|