Skip to content

Receipt v2 binding core + two inert settlement guards (D-147) - #1517

Merged
DealAppSeo merged 2 commits into
mainfrom
claude/2026-11-04-d147-receipt-v2
Nov 6, 2026
Merged

DealAppSeo merged 2 commits into
mainfrom
claude/2026-11-04-d147-receipt-v2

Conversation

@DealAppSeo

Copy link
Copy Markdown
Owner

What (all INERT — nothing wired to live scoring/settlement, nothing merged, no RepID moves, gates stay on)

Three independent pieces of D-147. The big one (receipt-binding.ts) is the reviewable brick for Receipt v2; the two guards close named red-team footguns.

1. receipt-binding.ts (NEW) — the versioned Receipt v2 binding

The signed combined receipt today commits to the HAL output + verdict, but not to the facts a payout depends on. v2 composes six into one content-hashed struct the engine signature would cover:

field swap it →
deliverable_sha256 the exact deliverable bytes
work_statement_hash the exact contract
evaluation_spec_hash the exact acceptance criteria
execution_origin the admission+grant it ran under
controller_assurance owner-evidence class (D-148 ladder)
settled_evidence_id the settlement it belongs to

verifyReceiptBinding rebuilds a binding from the material a holder presents and names each attack: swapped deliverable, changed acceptance criteria, forged PASS, substituted / stale / replayed origin, incorrect settlement. Origin authenticity is not re-implemented — it delegates to the existing, tested verifyExecutionOrigin primitive (one root set governs both). A v1 receipt has no binding and is never silently upgraded. 13 attack tests.

Left for Sean (MAJOR / HITL): embedding the binding into the issued/public signed receipt is a public-receipt-surface + canonical-settlement change (r23 / D-107). This file adds no field to any issued receipt and has zero callers in any scoring/settlement path.

2. lane-a-dryrun.ts — zero acceptance threshold is invalid

LANE_A_ACCEPT_THRESHOLD=0 made score >= 0 accept every deliverable, including all-criteria-failed (red-team #3). A 0 / <=0 / out-of-range knob now falls back to the default 1.0; valid range is (0, 1]. +5 tests.

3. evidence-pack.ts — a COMPLETE claim requires the task-existence check to have run

Adds complete, distinct from ok. A pack that cites trinity_tasks:<id> but was built without the opt-in taskEvidence join is grounded (ok) but not complete — existence was never verified. ok stays true for a legitimate non-task caller; only a completeness claim demands the join have run. +6 tests.

Verification

  • receipt-binding-d147.test.ts 13/13 · evidence-pack.test.ts + lane-a-dryrun.test.ts 33/33 · tsc --noEmit clean.
  • Base is origin/main (af51b05); diff is D-147-only (+459/−3).

Gate

Inert per r23 (no live state change). Independent of the D-148 PR (#1516). Merge is Sean's call.

🤖 Generated with Claude Code

…(D-147)

Inert: no live wiring, nothing merged, no RepID moves, gates stay on.

- receipt-binding.ts (NEW): the versioned v2 binding — deliverable_sha256,
  work_statement_hash, evaluation_spec_hash, execution_origin,
  controller_assurance, settled_evidence_id composed into one content-hashed
  struct the engine signature would cover. verifyReceiptBinding rebuilds from
  presented material and names each attack (swapped deliverable, changed
  criteria, forged PASS, substituted/stale/replayed origin, wrong settlement);
  origin authenticity delegates to the existing verifyExecutionOrigin. A v1
  receipt has no binding and is never silently upgraded. 13 attack tests.
  Embedding it into the issued/public signed receipt is left for Sean (MAJOR).

- lane-a-dryrun.ts: a 0 (or <=0) LANE_A_ACCEPT_THRESHOLD is now INVALID and
  falls back to 1.0 — a zero threshold made score>=0 accept every deliverable,
  including all-criteria-failed (red-team #3). +5 tests.

- evidence-pack.ts: add `complete`, distinct from `ok` — a pack that cites
  trinity_tasks:<id> but was built without the task-existence join is grounded
  (ok) but NOT complete; a completeness claim requires that check to have run.
  +6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Strix Security Review

To fix before merging (1)

PR summary: Adds an inert, unwired Receipt v2 binding module that content-hashes six settlement-relevant facts (deliverable, contract, acceptance criteria, execution origin, controller assurance, settlement id) for future signing, plus two hardening guards: a zero/out-of-range Lane A acceptance threshold now falls back to 1.0, and evidence packs expose a `complete` flag that requires the task-existence check to have actually run. Nothing is wired into scoring or settlement.

Review summary

Reviewed all six changed files. The new receipt-binding.ts was traced field by field: the cross-check added in this commit closes the prior stapled-deliverable gap (the presented origin digest is itself bound to the signed admission, so an authentic admission for one artifact cannot be re-used for another), and none of the six comparisons can be made to pass for tampered material. The one confirmed issue is the re-emitted Receipt v2 binding reports a replayed one-time mission admission as authentic when roots are supplied without a seen-nonce set: seenNonces remains optional, nothing reports that the replay check never ran, and the module's own happy-path test exercises exactly that unchecked call shape — still LOW because the module has zero callers. Both guard changes (acceptanceThreshold rejecting 0, and the new evidence-pack complete flag) hold as claimed and strictly narrow accepted input. One non-security observation worth a look outside this tracker: the new deliverable_origin_mismatch check compares the deliverable hash against artifact_digest, which every producer in this repo sets to the promoted verdict.json hash rather than the deliverable, so as written it would reject all honest receipts (fail-closed, not a bypass); the new test masks this with a fixture where artifact_digest = sha(deliverable).

1 resolved finding
Ruled out (7)
  • Can a deliverable be stapled onto an unrelated authentic run so the binding verifies anyway?
    The new cross-check compares the signed binding's deliverable hash against the presented origin digest, which verifyExecutionOrigin itself requires to equal the signed admission's artifact_digest, so the digest chain is closed and the check can only add a mismatch, never mask one.
  • Does evidence-pack's new `complete` flag let an unverified task citation be published as complete?
    `ok` semantics are unchanged by the diff (the expression is only hoisted into a local), `complete` strictly narrows what can be published, a truthy-but-empty taskEvidence fails cited_task_exists, and the module has no importers.
  • Can a tiny positive LANE_A_ACCEPT_THRESHOLD still accept an all-criteria-failed deliverable?
    Threshold 0 can never be reached through the resolver any more and score 0 cannot meet a positive threshold; the knob is a deploy-time env var with no writers anywhere and no production caller of the module.
  • Does the verifier throw instead of failing closed on malformed holder-supplied binding or origin input?
    The TypeError is real but unreachable: the API is typed so reaching it requires a caller to violate the declared types, the module has zero production importers, and the documented fail-closed refusal of a v1/unbound receipt is implemented correctly.
  • Can an attacker-minted binding or a forged PASS verify as ok:true through this module?
    The module is inert with zero callers and its header defers receipt embedding/signing to a separate change; the existing combined-receipt path already binds the verdict and cross-checks it against the signed evaluation attestation.
  • Do distinct deliverables collide in deliverable_sha256 via unpaired surrogates?
    The colliding strings are byte-for-byte identical after UTF-8 encoding, so no evaluatable content differs and the digest still uniquely binds every distinct byte sequence.
  • Do NaN/Infinity/null work-statement values collide in the binding field hashes?
    The mechanism is real in canonicalJson, but parseWorkStatement and the work-statement DB trigger reject every colliding value before hashing, canonicalJson is not modified by this PR, and the binding has no callers.
Review history (2)
  • Review 2 · 6836e69 · Nov 5 03:40 UTC · Fixed 1
  • Review 1 · ac366ee · Nov 5 02:08 UTC · Found 2 Low Vulnerabilities

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Review 2 on this PR · 6836e69 · requested Nov 5 03:34 UTC on a new commit


Reviewed by Strix
Re-run review · Configure security review settings

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged 2 new security findings below. See the pinned summary comment for the full PR status.

Comment thread src/services/receipt-binding.ts Resolved
Comment thread src/services/receipt-binding.ts Resolved
Comment thread src/services/receipt-binding.ts Resolved
Comment on lines +194 to +196
if (!origin.authentic) mismatches.push('origin_not_authentic');

return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Receipt v2 binding reports replayed one-time mission admission as authentic when the caller supplies roots but no seen-nonce set

Severity: LOW · CWE-294

verifyReceiptBinding documents replay as an origin attack it catches and states that a real check only needs the pinned admission/owner roots. Replay detection actually lives in verifyExecutionOrigin behind the optional seenNonces option, which the new module forwards but neither requires nor reports. A caller supplying roots only — what the docstring prescribes — gets ok: true and an empty mismatches for a replayed one-time mission admission and its owner grant: admission signature, root pinning, expiry, mission and grant binding all pass unchanged on a replay, and the admission nonce is never consulted. One admitted execution can therefore be presented repeatedly with different material and still verify as authentic, defeating the one-time-use guarantee the binding is meant to enforce at settlement. Nothing in the returned object distinguishes "replay checked and passed" from "replay check never ran".

Suggested change
if (!origin.authentic) mismatches.push('origin_not_authentic');
return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons };
if (!origin.authentic) mismatches.push('origin_not_authentic');
// Fail-visible: with no `seenNonces` the one-time-nonce replay check never ran. Say so, instead of returning a
// clean result that reads as "replay checked and passed" (the evidence-pack `task_existence_check_skipped` convention).
const originReasons = origin.authentic ? [] : [...origin.reasons];
const replayCheckRan = originOpts.seenNonces !== undefined;
if (!replayCheckRan) originReasons.push('replay_check_skipped');
return { ok: mismatches.length === 0, mismatches, originReasons, replayCheckRan };
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: Receipt v2 binding reports replayed one-time mission admission as authentic when the caller supplies roots but no seen-nonce set
Severity: LOW
CWE: CWE-294

`verifyReceiptBinding` documents replay as an origin attack it catches and states that a real check only needs the pinned admission/owner roots. Replay detection actually lives in `verifyExecutionOrigin` behind the optional `seenNonces` option, which the new module forwards but neither requires nor reports. A caller supplying roots only — what the docstring prescribes — gets `ok: true` and an empty `mismatches` for a replayed one-time mission admission and its owner grant: admission signature, root pinning, expiry, mission and grant binding all pass unchanged on a replay, and the admission nonce is never consulted. One admitted execution can therefore be presented repeatedly with different material and still verify as authentic, defeating the one-time-use guarantee the binding is meant to enforce at settlement. Nothing in the returned object distinguishes "replay checked and passed" from "replay check never ran".

Location: src/services/receipt-binding.ts:194-196
Context: Primary fix: report that the replay check did not run instead of returning a clean result
```
// Before:
  if (!origin.authentic) mismatches.push('origin_not_authentic');

  return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons };
// After:
  if (!origin.authentic) mismatches.push('origin_not_authentic');

  // Fail-visible: with no `seenNonces` the one-time-nonce replay check never ran. Say so, instead of returning a
  // clean result that reads as "replay checked and passed" (the evidence-pack `task_existence_check_skipped` convention).
  const originReasons = origin.authentic ? [] : [...origin.reasons];
  const replayCheckRan = originOpts.seenNonces !== undefined;
  if (!replayCheckRan) originReasons.push('replay_check_skipped');

  return { ok: mismatches.length === 0, mismatches, originReasons, replayCheckRan };
```

Location: src/services/receipt-binding.ts:145-150
Context: Supporting change: carry the replay-check state on the result type
```
// Before:
export interface BindingVerification {
  ok: boolean;
  mismatches: BindingMismatch[];
  /** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
  originReasons: string[];
}
// After:
export interface BindingVerification {
  ok: boolean;
  mismatches: BindingMismatch[];
  /** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
  originReasons: string[];
  /** false => no `seenNonces` was supplied, so the one-time-nonce replay check never ran; this is NOT "replay checked and passed". */
  replayCheckRan: boolean;
}
```

Location: src/services/receipt-binding.ts:168-171
Context: Supporting change: keep the no-binding refusal consistent with the new result type
```
// Before:
  if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
    // No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
    return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] };
  }
// After:
  if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
    // No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
    return { ok: false, mismatches: [], originReasons: ['no_v2_binding'], replayCheckRan: false };
  }
```

Location: src/services/receipt-binding.ts:156-156
Context: Documentation: the documented contract must name the nonce set, not the roots alone
```
// Before:
 * inert default) origin is never authentic, so a real check must supply the external roots.
// After:
 * inert default) origin is never authentic, so a real check must supply the external roots. It must ALSO supply
 * `seenNonces` — without it the one-time-nonce replay check never runs and `replayCheckRan` is false.
```

How to fix:
Treat a missing nonce set as an unrun check rather than a passed one. Either require a seen-nonce set for settlement-grade verification, or keep the option optional but expose that the replay check did not run: add a `replayCheckRan` flag that is false when no nonce set was supplied, add a `replay_check_skipped` origin reason, and correct the docstring so the documented contract names both the pinned roots and the nonce set. This follows the `task_existence_check_skipped` convention this same change adopts for the evidence pack, letting a settlement caller refuse when one-time use was never verified. Update the interface, both return paths, and the accompanying attack tests so a roots-only verification is asserted to be visibly distinguishable from a replay-checked one.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment on lines +145 to +150
export interface BindingVerification {
ok: boolean;
mismatches: BindingMismatch[];
/** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
originReasons: string[];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supporting change: carry the replay-check state on the result type

Suggested change
export interface BindingVerification {
ok: boolean;
mismatches: BindingMismatch[];
/** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
originReasons: string[];
}
export interface BindingVerification {
ok: boolean;
mismatches: BindingMismatch[];
/** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
originReasons: string[];
/** false => no `seenNonces` was supplied, so the one-time-nonce replay check never ran; this is NOT "replay checked and passed". */
replayCheckRan: boolean;
}

Comment on lines +168 to +171
if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
// No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supporting change: keep the no-binding refusal consistent with the new result type

Suggested change
if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
// No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] };
}
if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
// No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
return { ok: false, mismatches: [], originReasons: ['no_v2_binding'], replayCheckRan: false };
}

* Verify a holder's presented material against a signed binding. Rebuilds the binding from the material and
* compares field-by-field, then checks origin authenticity via the existing primitive. Fail-closed: a missing
* binding (a v1 receipt) is refused, never passed. Origin roots are passed through — with none pinned (the
* inert default) origin is never authentic, so a real check must supply the external roots.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation: the documented contract must name the nonce set, not the roots alone

Suggested change
* inert default) origin is never authentic, so a real check must supply the external roots.
* inert default) origin is never authentic, so a real check must supply the external roots. It must ALSO supply
* `seenNonces` — without it the one-time-nonce replay check never runs and `replayCheckRan` is false.

…ntic run

The six bound fields were each verified for self-consistency, but nothing tied
deliverable_sha256 to the artifact the origin attests. So a receipt could commit
one deliverable while its authentic execution origin attested DIFFERENT bytes,
and verifyReceiptBinding returned ok:true — 'provenance bound to the deliverable'
held only by coincidence (the fixture set artifact_digest = sha(deliverable)).

Add a case-insensitive cross-check: deliverable_sha256 must equal the origin's
artifact_digest, else 'deliverable_origin_mismatch'. New attack test isolates it
(every other field self-consistent, origin authentic -> this is the only failure).
Inert: no caller in any scoring/settlement path; +11/-1 plus one test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DealAppSeo

Copy link
Copy Markdown
Owner Author

Red-team follow-up (self-review before merge): added a 14th attack test and a 3-line verifier fix.

Gap found: the six bound fields were each verified for self-consistency, but deliverable_sha256 was never tied to the artifact the origin attests. A receipt could commit one deliverable while its authentic execution origin attested different bytes, and verifyReceiptBinding returned ok:true — a false pass. 'Provenance bound to the deliverable' held only by coincidence (the test fixture set artifact_digest = sha(deliverable)).

Fix: case-insensitive cross-check deliverable_sha256 === origin.artifact_digest, else deliverable_origin_mismatch. The new test isolates it (every other field self-consistent, origin authentic → this is the only failure).

Severity: integrity / defense-in-depth. It needs a buggy or dishonest issuer to be reachable (a holder can't desync a self-consistent signed binding), but the verifier is the independent gate and should catch a deliverable/origin desync rather than assume it. Still inert — no caller in any scoring/settlement path. 14/14 tests, tsc clean. rev 6836e69.

@DealAppSeo

Copy link
Copy Markdown
Owner Author

CI test red here was not this PR's diff. The only failure was tests/xc1-l1-regrade-2.test.ts [XC1-PF-01] (recipient_substitution expected, got []) — an XC1 NPA-preflight probe unrelated to receipt-binding. Root cause: the test's look-alike generator compares the seller address's last nibble with === 'f', but ethers returns an EIP-55 checksummed address, so a checksum-uppercased 'F' flips to a case-only change that evaluateNpa reads as the same address → no refusal → flake (~1/32 runs; it hit here, not on #1516). Fixed on its own branch in #1518. Re-running this PR's test job to clear it.

DealAppSeo added a commit that referenced this pull request Nov 6, 2026
…nibble flip) (#1518)

tests/xc1-l1-regrade-2.test.ts built its one-nibble-different recipient with
`seller.address[41] === 'f' ? '0' : 'f'`. ethers returns an EIP-55 checksummed
(mixed-case) address, so when the seller's last nibble is a checksum-uppercased
'F', `'F' === 'f'` is false and the flip appends 'f' — a CASE-only change that
evaluateNpa (which compares addresses case-insensitively, asserted by XC1-PF-00)
reads as the SAME address. The probe then sees no refusal (`[]`) and fails,
~1/32 runs (P(last nibble = f) x P(checksum uppercases it)). This intermittently
reddens the `test` job on unrelated PRs (seen on #1517, not #1516).

Fix: compare the last char case-insensitively so the flip is always
value-distinct. Proven over all last-nibble cases: uppercase 'F' now maps to a
genuinely different nibble; every other case is unchanged. Test-only; no src/
or behavior change.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@DealAppSeo
DealAppSeo merged commit 9b8ad5d into main Nov 6, 2026
19 of 20 checks passed
@DealAppSeo
DealAppSeo deleted the claude/2026-11-04-d147-receipt-v2 branch November 6, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant