Repository navigation
Phase4 - #5
Phase4#5
Conversation
VerifiableReceipt bundles the release receipt with its candidate so the verifier can recompute both hashes independently. verifyReceipt() re-runs verifyRelease() from the contract library, which validates policy and recomputes contractHash/outputHash from the parsed snapshot — any change to purpose, audience, dimensions, rows, or group sizes fails closed. Canary absence is checked over the serialized candidate text. When the optional events array is present, checkEvents() verifies: - at least one tool.approval_required before any tool.response - no duplicate tool-call IDs in approval events - canary absent from the serialized event stream Every error path returns a typed VerifyOutcome; the function never throws.
Positive cases: well-formed receipt passes with and without events, events=null treated as absent. Negative cases — 20 enumerated scenarios: - receipt_malformed: null/string/array/missing-fields/empty-fields/extra-keys - contract_hash_mismatch / output_hash_mismatch: tampered hashes - candidate_malformed: extra keys, null candidate - candidate_denied: bad purpose - canary_in_events: canary email and canary free_text in event stream - approval_missing: no tool.approval_required in events - release_before_approval: tool.response precedes approval - duplicate_approval_event: same tool-call id in two approval events - events_malformed: non-array and non-record-element events Fail-closed: never throws on any hostile input shape; verifyReceipt always returns a typed result with outcome != 'pass' for all malformed inputs.
cli.ts reads a VerifiableReceipt JSON from a file path argument or stdin, runs verifyReceipt(), and exits 0 (PASS) or 1 (FAIL). Internal I/O errors use exit 2. The output line format is: verify-receipt: PASS receipt=<id> query=<id> verify-receipt: FAIL outcome=<code> detail=<text> scripts/ts-loader.mjs is a Node ESM loader hook that resolves relative .js imports to .ts sources, required because the TypeScript project uses .js extensions for ESM imports while the sources remain .ts files. This lets Node 24's --experimental-strip-types run the CLI without a build step. package.json adds a verify-receipt script: npm run verify-receipt -- receipt.json cat receipt.json | npm run verify-receipt
PR Summary by QodoAdd fail-closed release receipt verification and CLI
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
1.
|
There was a problem hiding this comment.
🟡 Changes recommended
The event-ordering check mixes filtered sequence indices with unfiltered events indices, which can misclassify a valid event stream as release_before_approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR ("Phase4") adds a new verify-receipt evidence command to the Need-to-Know project. It introduces a self-contained verifier that takes a bundle of a release receipt plus its candidate (and optional TrueForge session events), recomputes and checks the contract/output hashes, re-runs the release contract policy, scans for canary values, and validates approval-before-release event ordering — always returning a typed result and never throwing. A CLI entrypoint, a Node loader hook to run the TypeScript sources without a build step, and an npm script wire it up for command-line use.
Changes:
- New
src/verify/module:receipt.ts(types/constants),verify.ts(coreverifyReceiptlogic), andcli.ts(stdin/file entrypoint with documented exit codes). - Comprehensive
verify.test.tscovering positive cases, malformed receipts, hash mismatches, canary containment, event ordering, and fail-closed adversarial input. - Tooling:
scripts/ts-loader.mjsloader hook and averify-receiptnpm script.
File summaries
| File | Description |
|---|---|
| src/verify/receipt.ts | Defines VerifiableReceipt, VerifyOutcome/VerifyResult types, and receipt-key/event-type constants. |
| src/verify/verify.ts | Core verifier: parsing, hash/policy verification, canary scan, and event-ordering checks. |
| src/verify/cli.ts | CLI reading JSON from file/stdin and mapping outcomes to exit codes. |
| src/verify/verify.test.ts | Test suite for the verifier across positive, negative, and adversarial inputs. |
| scripts/ts-loader.mjs | Node loader hook remapping .js imports to .ts for the CLI. |
| package.json | Adds the verify-receipt npm script. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 5
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
MachineLearning-Nerd
left a comment
There was a problem hiding this comment.
Reviewed at 87c3546 — pulled the branch, ran the suite (154/154), tsc and biome clean, and exercised the CLI end-to-end against a real vault release: PASS -> exit 0, tampered outputHash -> output_hash_mismatch, exit 1. Every finding below was reproduced against live code, not read off the diff; repro shapes are in the inline threads.
What's solid
- The parse layer reuses
snapshotRecord/snapshotArray, so the verifier gets Phase 2's single-read defenses (proto grafting, accessor re-reads, index aliases) for free — right call. - The never-throws contract holds, hash checks delegate to
verifyReleaseinstead of reimplementing, and the CLI header is honest about what the tool does NOT prove (origin, delivery, session binding).
Findings (3 bugs + 1 comment)
- High — the user's approval is never checked: a transcript where the user denied and the tool ran anyway verifies as
pass;resumePositionsis dead code. - High — every
tool.responserequires prior approval, so a realistic full session (describe/prepare responses before release's approval) false-fails withrelease_before_approval. - Medium — filtered
sequencemisaligns indices withevents: one type-less event false-fails a correctly ordered stream. - Low — the "Build a VerifiableReceipt…" comment describes a function that doesn't exist.
Minor, no thread needed: --experimental-loader prints a deprecation warning on every CLI run (Node suggests the register() form via --import); invalid-JSON input reports on stderr while the header says failures print the outcome to stdout, and shares exit 1 with verification failures; and with no file argument and no piped stdin the CLI blocks silently on a TTY.
1 and 2 are worth fixing before merge — together they mean the event check currently passes the transcript it must reject and rejects the transcript it must pass. Both fixes are small and live in checkEvents.
The event check verified that the agent asked, never that the user allowed: user.tool_approval positions were collected into a dead variable and a transcript recording an explicit deny followed by the tool running anyway verified as pass. Every gated tool.response must now carry a user.tool_approval with status "allow" between its approval request and its execution; anything else is user_approval_missing, a new outcome. The ordering rule also applied to every tool.response, so a real session — describe_dataset and prepare_analysis answering long before release_result asks — was rejected as release_before_approval, while the comment claimed the check was scoped to release_result. The check now applies only to responses whose tool_call_id was named in an approval request; ungated responses pass through. Positions are now real indices into the events array everywhere. The filtered sequence view shifted response positions left past their approval whenever a type-less event preceded them, false-failing a correctly ordered stream. Also drops a comment describing a builder function that was never written. Regression tests cover all four: denied-then-ran, no grant at all, full-session transcript, and a stream containing a heartbeat. AI-assisted (Claude Code); reviewed and understood by the author.
The verifier bound the receipt to the candidate's content through the two hashes but never to its identity: arbitrary queryId, datasetVersion, and policyVersion still verified as pass, so the command could certify a receipt naming the wrong query and versions. After approval the three fields are now compared against the candidate — provenance.queryId and the two version fields — and any difference is receipt_metadata_mismatch, a new outcome. receiptId has no candidate-side counterpart and is documented as an unverified display field. The canary_in_rows test asserted nothing: on its actual path (the contract denies the email canary) the body returned before calling verifyReceipt. Probing every carrier — a region value, both version fields, provenance.queryId — shows the contract pins every string field to a domain, constant, or pattern, so the canary scan is currently unreachable through an approved candidate. The scan stays as defence in depth against a future contract relaxation; the tests now pin the denials standing in front of it for both canary values, and say so. Stdin now reads file descriptor 0 instead of the Unix-only /dev/stdin, and the CLI registers its loader hook via --import (module.register) instead of the deprecated --loader flag, which also drops the deprecation warning from every run. Verified end to end: file arg, piped stdin, and a tampered hash all behave per the header. Found by Qodo and Copilot reviews on 87c3546. AI-assisted (Claude Code); reviewed and understood by the author.
No description provided.