Agent-trust layer: dual-signed receipts, ERC-8004 identity, x401 shadow, caller binding (all flag-gated) - #56
Agent-trust layer: dual-signed receipts, ERC-8004 identity, x401 shadow, caller binding (all flag-gated)#56Svaag wants to merge 7 commits into
Conversation
…t M1)
Every settled x402 payment across all paid routes now mints a receipt
carrying an ES256 compact JWS (offline-verifiable via /.well-known/jwks.json)
plus an EIP-712 secp256k1 signature over the sha256 of the same canonical
payload. New hyrule_cloud/trust/ package (mypy-strict), TrustConfig (TRUST_*,
default off), fulfillment_receipts table (migration 015, plus a duplicate-
settlement detection index on payment_events.tx_hash), HYRULE-RECEIPT
response header, and GET /v1/receipts/{id} retrieval.
Minting is best-effort and bounded (2s) inside PaymentGate's settle paths —
check_payment, settle_verified, and both dev-bypass branches — so a broken
or disabled trust layer never changes payment behavior; native BTC/XMR
receipts never disclose deposit addresses or txids, even hashed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fulfillment-kind receipts now cover the async outcomes: VM provisioned
(real + simulated, with provision timing), VM failed, VM extended, domain
registered/failed, BGP job completed (with artifact evidence), and native
BTC/XMR intents at first SETTLED observation. Refund-kind receipts are
minted at both refund choke points (RefundService.record_owed and the
atomic native REFUND_MANUAL flip), soft-linked to their refund_owed ledger
event, and idempotent.
Payment receipts gain order correlation via request.state.receipt_
correlation (quote/vm/domain set before the gate call). New GET
/v1/vm/{id}/receipts (management-gated), client + MCP tools (get_receipt,
list_vm_receipts), /metrics gains hyrule_receipts_total and a duplicate-
settled-tx gauge, and the x402 canary now verifies both receipt signatures
offline whenever a paid response advertises one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st M3) Discovery-only ERC-8004 surfaces, pinned to the current Draft (verified 2026-07-10): the registration document at /.well-known/agent-registration.json (type registration-v1, services, x402Support, registrations[] from config once the on-chain ceremony has run), flag-gated manifest `identity` and `receipts` blocks, and scripts/erc8004_register.py — a one-shot register/setAgentURI ceremony tool over raw JSON-RPC + eth-account (no web3 dep) that also prints the eth_getLogs monitoring query for the ops repo. The app runtime never reads chain state, so registry outages cannot affect service. A guard test pins /.well-known/x402.json byte-identical to the pre-trust manifest while all TRUST_* flags are off, protecting the pending launch announcement surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t M5+M6) x401 v0.2.0 identity-proof layer, pinned to the current draft (verified 2026-07-10): PROOF-REQUEST/PROOF-RESPONSE/PROOF-RESULT headers carrying base64url JSON, OpenID4VP DCQL predicates, header-authoritative semantics. TRUST_X401_MODE=off|shadow|enforce. Shadow evaluates the step-up policy (VM create/extend over duration/amount thresholds) and writes decisions to the new x401_proof_log (migration 016) — HTTP responses are test-pinned byte-identical to mode=off. The 402 challenge gains an advisory x401 extension while the mode is not off. Enforce (ships OFF; flipping is a human-controlled ops event) is proof-first-then-pay on /v1/vm/create only: a step-up order without a valid proof gets 401 + PROOF-REQUEST before any reservation and before the payment gate is ever invoked (gate call count 0, test-pinned). POST /v1/x401/proof verifies a Result Artifact via a pluggable verifier (v1 StructuralVerifier satisfies only under the test-only TRUST_X401_ACCEPT_STRUCTURAL flag — it honestly 503s otherwise) and issues a TTL-bounded hyr_pf_ token sha256-at-rest, scope-bound to the exact quote/amount/route/method. /metrics gains hyrule_x401_decisions_total. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trust M7) TRUST_PRINCIPAL_MODE=observe resolves RFC 9421 HTTP Message Signatures (minimal subset: @method + @target-uri, created/expires/keyid/alg params, ed25519 + ecdsa-p256-sha256) whose keyid is a did:web key. DID documents are fetched behind the existing SSRF guards (public-address pre-flight, 2s timeout, 64KB cap) with positive + negative TTL caches; ports and non-web DID methods are rejected. The resulting AgentPrincipal (did, key_id, verified) is recorded on request.state, merged into settled payment_events extras, and stamped into receipts' agent field + agent_did column. It never blocks, never authorizes, and a resolver outage or private DID host degrades to an unverified/absent principal with the request untouched (test-pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion runbook - docs/x402-compute-fulfillment-receipt.md — the open receipt profile: payload schema, JCS-subset canonicalization, dual-signature rules (ES256 JWS + EIP-712 ReceiptDigest), per-rail semantics incl. the native-rail privacy rules and a reserved Zcash slot. - docs/trust-layer.md — components, flag matrix, test-pinned invariants, spec pins (ERC-8004 draft + registry addresses, x401 v0.2.0), and the remaining ops roadmap (key ceremony/registration, Circle Gateway spike, receipt-backed feedback). - docs/runbooks/trust-keys.md — key generation/rotation ceremonies, the Sepolia→mainnet ERC-8004 registration procedure with human sign-off, and registry-event/duplicate-settlement monitoring for the ops repo. - scripts/verify_receipt.py — customer-facing offline verifier (fetches JWKS from the receipt's own origin so staging verification works). - docs/plans/2026-07-10-agent-trust-layer.md — the executed plan with progress + spec-pin deviations recorded. - CLAUDE.md — trust package in the file map; refreshed the not-yet-built list (refund recording shipped in #44; trust ops rollout is the open item). Verified end-to-end on a live app: paid call → HYRULE-RECEIPT header → /v1/receipts/{id} → both signatures verified offline; manifest/agent- registration/JWKS blocks present with flags on and byte-absent with flags off; migrations 015/016 up→down→up clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5597613306
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| async with httpx.AsyncClient( | ||
| timeout=_HTTP_TIMEOUT_SECONDS, follow_redirects=False | ||
| ) as client: | ||
| response = await client.get(url) |
There was a problem hiding this comment.
Pin DID document fetches to the vetted address
When TRUST_PRINCIPAL_MODE=observe and a request supplies a did:web keyid, this preflight checks the hostname with resolve_public_addresses, but client.get(url) performs a fresh DNS lookup. A DNS-rebinding DID host can return a public IP for the check and then a private/link-local IP for the actual HTTPS GET, turning signature observation into an SSRF to port 443 despite the guard. Reuse the vetted address or a transport that pins it while preserving Host/SNI.
Useful? React with 👍 / 👎.
| await x401.observe( | ||
| route=request.url.path, | ||
| method="POST", | ||
| amount=total, | ||
| duration_days=body.days, |
There was a problem hiding this comment.
Enforce x401 on advertised VM extensions
When TRUST_X401_MODE=enforce and an extension exceeds the duration/amount threshold, this code logs the STEP_UP decision and then proceeds to gate.check_payment without requiring PROOF-RESPONSE. Since the x401 policy/advisory advertises /v1/vm/{vm_id}/extend as a required-conditionally step-up route, clients can pay for elevated extensions without the proof the server says is required. Apply _x401_enforce_or_none here or stop advertising extend as enforced.
Useful? React with 👍 / 👎.
| row.status = "completed" | ||
| row.artifact_snapshot_id = body.artifact_snapshot_id | ||
| row.completed_at = datetime.now(UTC) | ||
| job_payer, job_tx, job_price = row.owner_wallet, row.payment_tx, row.price_usd | ||
| completed = True |
There was a problem hiding this comment.
Avoid minting duplicate job completion receipts
If the internal worker retries /v1/internal/bgp/jobs/{job_id}/complete after losing the response, the row is already completed but this path still sets completed = True, so the receipt block below mints another fulfillment receipt for the same paid job/artifact. That inflates receipt metrics and exposes multiple attestations for one completion; only mint when transitioning from a non-completed status.
Useful? React with 👍 / 👎.
| if int(created) > now + _CLOCK_SKEW_SECONDS: | ||
| return unverified | ||
| expires = params.get("expires", "") | ||
| if expires.isdigit() and int(expires) < now - _CLOCK_SKEW_SECONDS: |
There was a problem hiding this comment.
Reject stale caller signatures
When TRUST_PRINCIPAL_MODE=observe, a captured signature with an old created value and no expires is still accepted because only future timestamps and expired expires values are rejected. This contradicts the stated ±300s skew rule and lets a replayed signature for the same method/URI be recorded as a verified agent in ledger rows and receipts long after it was signed; also reject created < now - _CLOCK_SKEW_SECONDS.
Useful? React with 👍 / 👎.
| request.state.receipt_correlation = { | ||
| "quote_id": quote_row.quote_id if quote_row is not None else None, | ||
| "vm_id": reservation_row.vm_id if reservation_row is not None else None, |
There was a problem hiding this comment.
Avoid stamping payment receipts with provisional VM ids
When a paid VM create uses a pre-payment reservation, the payment receipt is minted during gate.check_payment, before the reservation is known to survive. If the reservation is later released or replaced (for example a consumed-quote race or a reservation swept during a very slow settlement), the returned HYRULE-RECEIPT points at a VM id that was deleted or never fulfilled. Only correlate the payment receipt to the stable quote/order here, and let fulfillment receipts carry the final VM id.
Useful? React with 👍 / 👎.
Bring #69's ed25519 response-body signing under the canonical trust layer instead of shipping a parallel signing stack. Measurements attest the DATA; receipts attest the transaction — independent flags, one key namespace, one JWKS. - trust/measurements.py: MeasurementSigner (ed25519 detached signature over exact 2xx JSON body bytes), verify_measurement_signature, load_measurement_signer, enforce_measurement_key_guard (fail-closed boot guard mirroring receipts), measurement_jwks_entries. - middleware/signing.py: ResponseSigningMiddleware — signs paid 2xx JSON from the enabled catalog, Hyrule-Signature/-Key headers + CORS expose; passthrough when unconfigured (soft-fail). - config TrustConfig: TRUST_MEASUREMENT_SIGNING_ENABLED/KEY/KEY_ID/ RETIRED_JWKS_JSON (default off). - Unified JWKS: build_jwks appends the OKP/Ed25519 key (active + retired) so one /.well-known/jwks.json verifies receipts AND measurements. - Advertise under agent-registration signedMeasurements only — NOT the x402 manifest, preserving the byte-identical-off invariant. - app.py: enforce_measurement_key_guard + middleware install. - x402_canary.py: --verify-signature re-derives the pubkey from jwks. - Docs: trust-layer component row, trust-keys runbook section + rotation, .env.example block. 9 new tests; 397 pass, mypy strict + ruff clean. Supersedes and closes #69. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Folded #69 (ed25519 signed measurements) into this PR — commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b46e429339
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| def _is_paid_operation(scope: Scope) -> bool: | ||
| method = scope.get("method", "").upper() | ||
| path = (scope.get("path", "") or "").rstrip("/") or "/" | ||
| for op_method, template, regex in _matchers(): | ||
| if op_method != method or not regex.fullmatch(path): | ||
| continue | ||
| # discovery_for re-applies the per-op gate on the template, so a gated | ||
| # -off op (which 501s) is never treated as signable. | ||
| if discovery_for(op_method, template) is not None: | ||
| return True |
There was a problem hiding this comment.
Sign every paid JSON response, not only Bazaar routes
With measurement signing enabled, a successful paid POST /v1/vm/{vm_id}/extend is not signed because this predicate only accepts paths listed in DISCOVERY, which contains /v1/vm/create but not the paid extend route. The same gap applies to other paid routes that are not Bazaar declarations. This contradicts the configured and documented contract of signing each paid 2xx JSON body, leaving buyers unable to verify those delivered results; derive this from the payment gate/route metadata or add all paid routes to the signed-operation set.
Useful? React with 👍 / 👎.
| def quote_hash(*, quote_id: str | None, amount: Decimal | str | None, route: str, method: str) -> str: | ||
| """Deterministic binding of a proof to one quoted purchase: the token is | ||
| only valid for the same quote/amount/route/method it was issued for.""" | ||
| material = f"{quote_id or ''}|{amount if amount is not None else ''}|{route}|{method}" | ||
| return hashlib.sha256(material.encode()).hexdigest() |
There was a problem hiding this comment.
Canonicalize proof amounts before hashing
In enforce mode, a proof submitted with a numerically correct but differently formatted amount (for example "9") hashes differently from the create route's computed Decimal("9.00"), so the retry is rejected as proof_invalid. This is especially problematic for unquoted creates because the PROOF-REQUEST returns only the hash, not the server's exact decimal spelling for clients to reproduce. Hash a normalized decimal representation (or bind to a server-issued quote) so equivalent amounts produce the same token binding.
Useful? React with 👍 / 👎.
| RPC outage can never affect this endpoint (soft-fail invariant). The | ||
| same-origin serving location is what proves domain control to registry | ||
| consumers; `registrations` appears only once the on-chain ceremony | ||
| (scripts/erc8004_register.py, human-controlled) has produced an agentId. | ||
|
|
||
| Domain policy (AGENTS.md): this is customer-facing Hyrule Cloud | ||
| identity — it lives under hyrule.host, never servify.network or | ||
| as215932.net. |
There was a problem hiding this comment.
Do not advertise disabled receipt endpoints in the agent card
When TRUST_AGENT_CARD_ENABLED=true but TRUST_RECEIPTS_ENABLED=false, this unconditional services list advertises /v1/receipts/{receipt_id} even though get_receipt returns 404 whenever receipts are disabled. The flags are intentionally independent, so an identity-only rollout publishes a service agents cannot use; add the receipt and JWKS service declarations only when their corresponding trust functionality is enabled.
Useful? React with 👍 / 👎.
| evidence=( | ||
| {"artifact_snapshot_id": str(body.artifact_snapshot_id)} | ||
| if body.artifact_snapshot_id | ||
| else None | ||
| ), |
There was a problem hiding this comment.
Include the delivered artifact digest in job evidence
For a completed BGP job this receipt records only the opaque snapshot ID, even though BGPSnapshotRow stores the artifact's sha256 and the receipt profile promises a delivered artifact hash as evidence. A recipient who downloads the gzip cannot verify that it is the artifact being attested, so the receipt is not usable as the advertised delivery evidence; load the snapshot and include its digest (and preferably format) in evidence.
Useful? React with 👍 / 👎.
Implements the accepted trust-layer plan (
docs/plans/2026-07-10-agent-trust-layer.md) across every x402 service: ERC-8004 identifies the service, x401 proves authority when necessary, x402 proves payment, dual-signed receipts attest outcomes — Hyrule stays the deterministic execution engine.Everything lands dark: all
TRUST_*flags default off, and a guard test pins/.well-known/x402.jsonbyte-identical to today's announced surface while they stay off (protects #53). Rollout is #54.What's here (one commit per milestone)
0e6abb3):hyrule_cloud/trust/(mypy-strict). Every settled payment on every paid route mints a receipt signed twice over the same canonical bytes — ES256 compact JWS (offline-verifiable via/.well-known/jwks.json) + EIP-712ReceiptDigest(secp256k1, verifiable by EVM tooling / usable as ERC-8004 feedback evidence). Minted insidePaymentGate's settle paths (incl.settle_verified+ dev-bypass), so all 13 intel routers, proxy, VM, domain, jobs — and future speedtest/mail — are covered with zero per-route changes.HYRULE-RECEIPTresponse header,GET /v1/receipts/{id}, migration 015 (+ix_payment_events_tx_hashduplicate-settle detection — deliberately not a uniqueness constraint on the best-effort ledger). WithTRUST_RECEIPTS_ENABLED=truethe app refuses to boot unless both keys validate.9521017): orchestrator (provisioned/failed/simulated, with provision timings), extend, domain register, BGP job completion (artifact hash evidence), native BTC/XMR intents at first SETTLED, and both refund choke points (soft-linked to theirrefund_owedledger event, idempotent).GET /v1/vm/{id}/receipts,hyrule_receipts_total+hyrule_payment_duplicate_settled_txmetrics, MCP/client tools, and the x402 canary now fails if an advertised receipt doesn't verify. Privacy invariant (test-pinned): native-rail receipts carry no deposit address and no txid, even hashed — correlation is the unguessable intent id.156675b): registration document at/.well-known/agent-registration.json(spec pinned 2026-07-10 — the draft's filename, not the A2A agent-card guess), flag-gated manifestidentity/receiptsblocks, andscripts/erc8004_register.py(raw JSON-RPC + eth-account, no web3 dep; prints the ops-repoeth_getLogsmonitoring query). The app runtime never reads chain state.a0c6a59): v0.2.0 pinned (PROOF-REQUEST/PROOF-RESPONSE/PROOF-RESULT, base64url JSON, DCQL).TRUST_X401_MODE=shadowlogs what enforcement WOULD require (migration 016) with HTTP responses byte-identical to off (test-pinned); the 402 gains an advisory x401 extension. Enforce (ships off) is proof-first-then-pay on/v1/vm/createonly: 401 + PROOF-REQUEST before reservation and before the gate — a payment header without a valid proof never reaches verify/settle (gate call count 0, test-pinned).POST /v1/x401/proofissues TTL-boundedhyr_pf_tokens scope-bound to quote/amount/route/method; the structural verifier only satisfies under a test-only flag and honestly 503s otherwise.094a5e6): RFC 9421 → did:web, observe-only. Verified principals flow into ledger extras + receiptagentfields; SSRF-guarded resolution with positive/negative caches; every failure degrades to unverified/absent with the request untouched.5597613):docs/trust-layer.md, the open profiledocs/x402-compute-fulfillment-receipt.md,docs/runbooks/trust-keys.md(ceremonies, rotation, monitoring),scripts/verify_receipt.py, plan copy, CLAUDE.md refresh.Verification
ARRAY, so chain-tested from a stamped 014; prod runs the real chain viaExecStartPre=alembic upgrade headas usual)./v1/dns/lookup→HYRULE-RECEIPTheader → both signatures verified offline viascripts/verify_receipt.py; receipt soft-links its ledger event; 402 carriesbazaar+x401advisory; agent-registration/JWKS/manifest blocks all serve. Flags off: no new headers, no manifest keys, agent-registration 404, JWKS empty.test_launch_blockersfailures on main are pre-existing (stalex402.orgfacilitator in fixtures) — filed as Stale x402.org facilitator in .env.example era: 7 test_launch_blockers tests fail on clean main #55, untouched here.Follow-ups
🤖 Generated with Claude Code