Skip to content

feat(storyboard): outbound-webhook conformance runner (#2426) + 9421 webhook verifier#631

Merged
bokelley merged 9 commits into
mainfrom
bokelley/adcp-2426
Apr 20, 2026
Merged

feat(storyboard): outbound-webhook conformance runner (#2426) + 9421 webhook verifier#631
bokelley merged 9 commits into
mainfrom
bokelley/adcp-2426

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Implements the storyboard runner extension tracked at adcontextprotocol/adcp#2426 and ships the RFC 9421 webhook-signing verifier the webhook-emission universal (adcp#2431) depends on.

What this does

Lets an AdCP compliance runner grade a publisher's outbound webhook behavior — idempotency_key presence, idempotency_key stability across retries, and RFC 9421 signature validity — against live traffic. Today these are load-bearing AdCP 3.0 requirements with no programmatic grading surface.

Storyboard runtime

  • runStoryboard / runStoryboardStep accept a webhook_receiver option that binds an ephemeral HTTP listener. Two endpoint modes: loopback_mock (default, loopback-only, zero setup) and proxy_url (operator-supplied public base, for AdCP Verified grading against remote agents).
  • Receiver mints per-step URLs under /step/<step_id>/<operation_id> and exposes {{runner.webhook_base}} / {{runner.webhook_url:<step_id>}} substitutions so storyboards can inject them into push_notification_config.url. Downstream filters pick up the same operation_id via {{prior_step.<step_id>.operation_id}}.
  • Three new step-task pseudo-tasks (observe the shared receiver rather than drive the agent):
    • `expect_webhook` — asserts a matching delivery arrived carrying a well-formed `idempotency_key` (pattern `^[A-Za-z0-9_.:-]{16,255}$`). Optional `expect_max_deliveries_per_logical_event` caps distinct logical events — catches publishers that re-execute on replay under a fresh key.
    • `expect_webhook_retry_keys_stable` — configures the receiver to reject the first N deliveries with a configurable 5xx, then asserts every observed delivery carries the byte-identical `idempotency_key`. Fails with `insufficient_retries`, `idempotency_key_rotated`, or `idempotency_key_format_changed`.
    • `expect_webhook_signature_valid` — delegates to the new RFC 9421 webhook verifier. Grades `not_applicable` when `webhook_signing` is not configured.
  • `requires_contract` on any webhook-assertion step grades `not_applicable` when the contract id is not listed in `options.contracts` — lets cross-cutting storyboards reference webhook assertions without forcing every runner to host a receiver.

RFC 9421 webhook verifier

  • `verifyWebhookSignature` in `@adcp/client/signing/server` — 14-step checklist per security.mdx#verifier-checklist-for-webhooks. Tag `adcp/webhook-signing/v1`, mandatory covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, key purpose `adcp_use: "webhook-signing"`.
  • Full `webhook_signature_*` error taxonomy (`header_malformed`, `params_incomplete`, `tag_invalid`, `alg_not_allowed`, `window_invalid`, `components_incomplete`, `key_unknown`, `key_purpose_invalid`, `key_revoked`, `revocation_stale`, `rate_abuse`, `invalid`, `digest_mismatch`, `replayed`) — distinct codes so compliance reports distinguish remediation paths. Exhaustive storyboard-side mapper catches missing mappings at compile time.
  • Shared `InMemoryReplayStore` / `InMemoryRevocationStore` across all signature checks per run so cross-step `(keyid, nonce)` replay is actually detected.
  • `signWebhook` companion helper in `@adcp/client/signing/client` (with `tag` override for negative testing).
  • Parser enforces the spec's single-alphabet rule on byte-sequence tokens (rejects mixed `[-_]` + `[+/=]`) — benefits both verifiers.

Hardening (from expert review)

Security + protocol + code reviewers approved; feedback fully incorporated in commit `f8f865d6`:

  • Clamp storyboard-declared knobs: `retry_trigger.count` ≤ 10, `timeout_seconds` ≤ 300, `http_status` allowlist `{429, 500, 502, 503, 504}`. Prevents a typo'd YAML from turning the runner into a DoS amplifier.
  • Validate `public_url`: http(s) scheme, no userinfo, no CR/LF/NUL. Reject `0.0.0.0` / `::` in `loopback_mock` mode.
  • Redact `SECRET_HEADER_PATTERN` (authorization, cookie, api-key, …) on captured webhooks before they land in compliance reports.
  • HTTP transport caps: `headersTimeout=10s`, `requestTimeout=30s`, `keepAliveTimeout=5s`, `maxConnections=64`. `closeAllConnections` on shutdown. Per-key + total capture caps with 503 back-pressure.
  • Split window errors (`webhook_signature_expired` → `webhook_signature_window_invalid`) per spec.

Conformance vector harness (adcp#2445)

  • Vendored 7 positive + 21 negative vectors under `test/fixtures/webhook-signing-vectors/` (AdCP tarball hadn't re-released post-PR #2445 at the time of this work; ready to swap to `compliance/cache/…` on the next sync).
  • New `test/lib/webhook-signing-vectors.test.js` runs every vector through `verifyWebhookSignature`. State-dependent vectors (replay, revocation, rate-abuse, revocation-stale) install their `test_harness_state` into fresh stores per run. 28 / 28 pass.

Upstream interaction

Filed or surfaced three upstream drifts while building this:

End-to-end

  • In-process E2E (`test/lib/storyboard-webhook-signature.test.js`): Ed25519 keypair → `signWebhook` → real HTTP POST → receiver captures → `verifyWebhookSignature` passes. Covers happy path, wrong tag (`signature_tag_invalid`), unknown kid (`signature_key_unknown`).
  • Not covered in this PR: a full `createAdcpServer` → auto-emit signed webhook → runner receives / verifies round-trip. That requires a `createWebhookEmitter` publisher helper + `createAdcpServer` integration — flagged as a follow-up PR. Today publishers have `signWebhook` (the crypto primitive) but no ergonomic emitter; closing that gap is the next piece.

Test plan

  • `npm run typecheck` — clean
  • `npm test` — 4132 pass / 0 fail / 8 annotated skips (4 × adcp#2488 drift, 1 × adcp#2468 forward-drift, 3 × verifier-side `isPathReachable` limitation with open follow-up)
  • Vector conformance: 28 / 28 pass
  • In-process signing E2E: 3 / 3 pass
  • CI pass (pending)

Follow-up PRs

  • `createWebhookEmitter` + brand.json → JWKS auto-resolver + `createAdcpServer` integration (unlocks full "spin up a publisher, watch the stack verify" E2E and Claude-skill usage).
  • `wait_all` early-resolve on quiescence (cuts ~1s off each retry-stability test).
  • `isPathReachable` extension to handle `oneOf` after Zod codegen (unblocks the one `VERIFIER_UNREACHABLE` entry).

Changesets

  • `.changeset/storyboard-webhook-receiver.md` — `minor`
  • `.changeset/webhook-signing-vectors-gap.md` — `minor`

bokelley and others added 6 commits April 19, 2026 19:50
Ephemeral receiver + expect_webhook validation (context-keyed URL
substitution, flat validation shape). To be replaced by the
spec-aligned step-type approach per adcontextprotocol/adcp#2431.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2426)

Rework to match adcontextprotocol/adcp#2431:

- Runner variables: {{runner.webhook_base}} / {{runner.webhook_url:<id>}} /
  {{prior_step.<id>.operation_id}} substitution (embedded, alongside
  existing anchored $context / $generate).
- Receiver: per-step URL routing (/step/<step_id>/<operation_id>),
  delivery_index tracking, configurable 5xx-then-2xx retry-replay policy,
  loopback-mock + proxy-url modes.
- Three step-task pseudo-tasks (not validations): expect_webhook,
  expect_webhook_retry_keys_stable, expect_webhook_signature_valid —
  each with the spec's error-code taxonomy.
- requires_contract + options.contracts for not_applicable gating.

RFC 9421 webhook verifier (src/lib/signing/webhook-verifier.ts):

- Tag adcp/webhook-signing/v1, key purpose adcp_use:"webhook-signing",
  mandatory covered components @method / @target-uri / @authority /
  content-type / content-digest.
- Companion signWebhook helper for publisher SDKs.
- WebhookSignatureError with webhook_signature_* code taxonomy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security review:
- Clamp storyboard-declared retry_trigger.count (MAX=10) and timeout_seconds
  (MAX=300) so a typo'd YAML can't DoS a real seller.
- Allowlist retry_trigger.http_status (429, 500, 502, 503, 504).
- Validate webhook_receiver.public_url: http(s) scheme, no userinfo, no
  CR/LF/NUL. Reject 0.0.0.0 / :: in loopback_mock mode.
- Redact SECRET_HEADER_PATTERN (authorization, cookie, api-key, ...) on
  captured webhooks before they land in compliance reports.
- HTTP transport caps: headersTimeout=10s, requestTimeout=30s,
  keepAliveTimeout=5s, maxConnections=64, maxHeadersCount=64. closeAllConnections
  on shutdown.
- Per-key + total capture caps (503 back-pressure when hit).
- Share InMemoryReplayStore / InMemoryRevocationStore across all
  expect_webhook_signature_valid calls in a run so cross-step (keyid, nonce)
  replay is actually detected.

Protocol conformance review:
- Split webhook_signature_expired into expired vs window_invalid — distinct
  remediation paths (clock skew vs signer bug).
- Exhaustive mapSignatureErrorCode: every webhook_signature_* code maps to
  a storyboard signature_* code, with a compile-time exhaustiveness check.
  New storyboard codes: signature_window_invalid, signature_alg_not_allowed,
  signature_components_incomplete, signature_header_malformed,
  signature_params_incomplete, signature_key_revoked.

Code review:
- Top-level randomUUID import, drop runtime require('node:crypto').
- Narrow unsafe cast in buildFilter with typeof guards.
- Add tag option to signWebhook so wrong-tag test stops mutating headers.
- Stable-key deepEqual (object key-order no longer affects filter matches).
- Remove unused _runnerVars param, dead state = { retryCount } scaffolding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align with the merged spec enum (security.mdx#webhook-callbacks) and the
canonical test vectors shipped by adcontextprotocol/adcp#2445.

Error taxonomy (verifier + storyboard):
- Drop non-spec webhook_signature_expired — every window failure is
  webhook_signature_window_invalid. Same for signature_expired on the
  storyboard side.
- Add webhook_signature_rate_abuse (step 9a, distinct from replayed).
- Add webhook_signature_revocation_stale (step 9, revocation past grace).
- Realign verifier step numbers to canonical 1–13 + 9a.
- Re-map shared RevocationStore's request_signature_revocation_stale to
  the webhook code via try/catch at the call site.
- Exhaustive storyboard mapSignatureErrorCode switch catches missing
  mappings at compile time.

Parser hardening:
- Reject base64 tokens mixing [-_] with [+/=] within one byte-sequence
  delimiter — spec requires *_header_malformed. Benefits both verifiers.

Conformance harness:
- Vendor the 7 positive + 21 negative test vectors from adcp main under
  test/fixtures/webhook-signing-vectors/ (tarball not yet re-released;
  swap to compliance/cache/... on next sync).
- New test/lib/webhook-signing-vectors.test.js runs every vector through
  verifyWebhookSignature. State-dependent vectors install their
  test_harness_state into fresh stores per run.
- 2 positive vectors (004, 005) skipped pending an upstream regen:
  their baked signatures contradict the request-signing canonicalization
  rules the webhook spec inherits.

Test drift plumbing:
- HARNESS_TASKS in storyboard-completeness now knows about the three
  expect_webhook* pseudo-tasks (sync-schemas pulled webhook-emission.yaml).
- storyboard-drift.test.js gets a KNOWN_FORWARD_DRIFT allowlist for
  webhook_emission/get_capabilities:operations (field lands in a future
  schema regen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tation

- Vendored vectors 004/005 now carry the fixed signatures per adcp#2470
  (full @target-uri canonicalization: :443 stripped, %xx uppercased on
  reserved bytes). Dropped SKIP_POSITIVE — all 7 positives + 21 negatives
  verify cleanly against our verifier.

- storyboard-drift: adcontextprotocol/adcp#2468 (webhook-emission
  operations → supported_protocols) is merged on main but hasn't shipped
  in the published tarball yet. Kept the entry in KNOWN_FORWARD_DRIFT
  with a pointer comment so the allowlist clears on the next sync-schemas.

- storyboard-drift: new VERIFIER_UNREACHABLE allowlist for paths that
  are structurally valid in the spec but that our isPathReachable can't
  resolve after Zod codegen. Seeded with
  idempotency/get_capabilities:adcp.idempotency.supported — the oneOf
  wrapping around adcp.idempotency isn't unwrapped by our traversal.
  Verifier-side limitation, not spec drift; tracked for a follow-up
  isPathReachable extension.

- skipReason() helper now dispatches by category so both field_present
  and field_value checks honor the same allowlists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit and cleanup of upstream-drift tracking:

- Narrowed blanket storyboard skip for media_buy_seller/inventory_list_targeting
  to the 4 specific field paths that fail reachability (targeting_overlay.{property_list,collection_list}.list_id
  in get_after_create / get_after_update). Previously the whole storyboard
  was masked; now its other validations (response_schema, field_value on
  list_id) run and pass — regressions in that storyboard are visible again.

- New UPSTREAM_SCHEMA_DRIFT set citing adcp#2488: PackageStatus lacks
  targeting_overlay, so get_media_buys can't echo the property_list /
  collection_list the seller persisted. Entries hold until the issue
  resolves or a replacement schema surface lands.

- Removed awaitingUpstreamTarball dead code in request-signing-vectors.test.js.
  The dynamic digest check has been evaluating to an empty KNOWN_UPSTREAM_BUG
  since the tarball absorbed the adcp-side fix; the inline #2335 citation
  was stale (PR number doesn't exist on the adcp repo). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/lib/testing/storyboard/context.ts Fixed
bokelley and others added 3 commits April 19, 2026 23:33
- Regenerated core/tools/schemas from current tarball. Upstream added
  SyncCreativesSubmitted + new ErrorCode values since the last regen.
- IdempotencySupported discriminator: create-adcp-server now emits
  `supported: true` alongside `replay_ttl_seconds` so the value satisfies
  the discriminated-union type.
- Prettier: formatted webhook-verifier, webhook-assertions, webhook-receiver,
  and the two changeset files. All code this PR touches passes --check.
- .prettierignore now excludes test/fixtures/webhook-signing-vectors/
  (vendored upstream conformance vectors; byte-identical to adcp main —
  auto-formatting would silently diverge from the source of truth) and
  compliance/cache/ (synced artifacts, never manually edited).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the new tools / error codes / storyboards landed in the latest
tarball (which CI regenerates and then diffs against the branch).
CodeQL flagged polynomial-ReDoS on the mustache-token regex:
  /\{\{(runner\.[^}]+|prior_step\.[^}]+)\}\}/g

The `[^}]+` alternatives could backtrack over a pathological input like
`{{runner.{{runner.…` — the inner class permitted the match to straddle
a nested `{{`, so the engine retried character-by-character on each
position with a `{{` anchor. Polynomial worst case.

Fix: restrict the inner class to `[^{}]+` so tokens can't cross a
nested opening brace, and unify the two alternation branches behind a
single non-capturing prefix group:

  /\{\{((?:runner|prior_step)\.[^{}]+)\}\}/g

Added regression test: 5,000-repetition `{{runner.` prefix returns
untouched in under 100 ms. Pre-fix runtime grew quadratically at this scale;
post-fix it's linear and unmatchable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 2756df6 into main Apr 20, 2026
14 checks passed
bokelley added a commit that referenced this pull request Apr 20, 2026
Publisher-side webhook emission — the symmetric counterpart to PR #629's
receiver dedup. One ctx.emitWebhook(url, payload, operation_id) call and
the emitter handles:

- RFC 9421 signing with fresh nonce per attempt (adcp#2423).
- Stable idempotency_key per operation_id across retries (adcp#2417).
- JSON serialized once with compact separators and posted byte-identically
  (adcp#2478) — closes the Python json.dumps-default-spacing trap.
- Retry with exp backoff + jitter on 5xx/429. Terminal on 4xx and on 401
  responses carrying WWW-Authenticate: Signature error="webhook_signature_*".
- Pluggable WebhookIdempotencyKeyStore (default in-memory); swap in a
  durable backend for multi-replica publishers.
- HMAC-SHA256 / Bearer fallback modes for legacy buyers.

createAdcpServer integration: new webhooks? config option. When set,
ctx.emitWebhook is populated on every handler's context — completion
handlers post signed webhooks without constructing the signer, fetching,
or tracking idempotency themselves.

Tests:
- 14 unit tests covering every delivery path (happy / retry / terminal /
  401+Signature / cap / cross-call stability / HMAC + Bearer fallback /
  observability).
- 3 full-stack E2E tests: createAdcpServer → ctx.emitWebhook → real HTTP
  → receiver captures → verifyWebhookSignature accepts. No mocks on the
  signer/verifier path. Closes the "haven't spun up an actual server"
  gap flagged during PR #631 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 20, 2026
`BrandJsonJwksResolver` lets a webhook receiver discover a sender's
JWKS by fetching their brand.json and extracting `agents[].jwks_uri`,
removing the need to pre-configure a JWKS URL per counterparty.

Delivers the `brand.json → JWKS auto-resolver` item from PR #631's
follow-up list. Companion `createWebhookEmitter` (publisher side) and
`createAdcpServer` integration remain as future work.

- Follows `authoritative_location` + `house` redirect variants up to
  `maxRedirects` hops (default 3) with explicit loop detection.
- Spec fallback to `/.well-known/jwks.json` on the agent origin when
  `jwks_uri` is absent.
- Brand.json cache: ETag + Cache-Control max-age, capped by
  `maxAgeSeconds`. Unknown-kid refresh cascades from the inner JWKS
  to brand.json so a rotated `jwks_uri` is picked up without restart.
- Ambiguous selectors (multiple agents of the same type, no agentId)
  throw with the candidate ids listed.
- All fetches go through `ssrfSafeFetch` so attacker-supplied URLs
  can't reach private addresses or IMDS.

10 new tests cover flat brand, portfolio (house + brands), redirect
chains, fallback path, selector ambiguity, rotation-across-maxAge,
304 cache-hit, and an end-to-end webhook verify using the resolver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 20, 2026
Delivers the `brand.json → JWKS auto-resolver` item from PR #631's
follow-up list. Companion `createWebhookEmitter` (publisher side) and
`createAdcpServer` integration remain as future work.

## `BrandJsonJwksResolver`

Receiver-side ergonomic: instead of pre-configuring a `jwks_uri` per
counterparty, point the verifier at the sender's brand.json and the
resolver walks `agents[]`, extracts the right `jwks_uri`, and delegates
caching to `HttpsJwksResolver`. Plugs into `verifyWebhookSignature.jwks`
(or `verifyRequestSignature.jwks`) with no changes required to existing
verifier call sites.

## Behavior

- Follows `authoritative_location` and `house` redirect variants up to
  `maxRedirects` hops (default 3) with explicit loop detection.
- Structurally validates every redirect target (scheme, no userinfo,
  fragments stripped) before dispatch. The `house` string variant is
  gated on a bare-hostname regex so an attacker-supplied brand.json
  cannot inject userinfo or paths via the `https://${house}/…`
  interpolation.
- Honors the spec fallback to `/.well-known/jwks.json` on the agent
  origin when `jwks_uri` is absent — but only when that origin matches
  the final brand.json origin. Cross-origin fallback is rejected with
  `jwks_origin_mismatch`; publishers hosting their agent on a different
  origin must declare an explicit `jwks_uri`.
- Brand.json cache tracks ETag + Cache-Control max-age (capped by
  `maxAgeSeconds`, default 1h). Unknown kid cascades: the inner JWKS
  refreshes first; if still unknown and the brand.json cooldown has
  elapsed, brand.json re-resolves to pick up a rotated `jwks_uri`.
- Ambiguous selectors throw `agent_ambiguous` with candidate ids.
- All fetches go through `ssrfSafeFetch`, so an attacker-supplied
  brand.json or JWKS URL cannot reach the receiver's private network
  or IMDS.
- Typed `BrandJsonResolverError` with stable `code` enum lets verifier
  callers fold transient failures into `webhook_signature_key_unknown`
  without parsing error message strings.

## Test coverage

18 tests: flat brand, portfolio (house + brands, brand-over-house
precedence), authoritative_location redirect chain, jwks_uri fallback,
selector ambiguity, rotation across maxAge, 304 cache hit, end-to-end
webhook verify, cross-origin fallback rejection, malformed house
string, malformed URL, userinfo injection, private-IP refusal under
default posture, maxRedirects exceeded, schema-invalid agent.url,
non-JSON body.

Regenerate core/schemas/tools types for upstream schema drift
(plan_hash on audit log, push_notification authentication precedence
note that validates the resolver's role).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 20, 2026
The compliance cache now ships six new request-signing negative vectors
that didn't exist at PR #631 merge:

  021-duplicate-signature-input-label
  022-multi-valued-content-type
  023-multi-valued-content-digest
  024-unquoted-string-param
  025-jwk-alg-crv-mismatch (uses jwks_override instead of jwks_ref)
  026-non-ascii-host

Two load-bearing issues on main:

1. Vector 025 ships a deliberately-malformed JWK inline via
   `jwks_override` instead of referencing keys.json. The vector loader
   threw on parse because it only understood `jwks_ref: string[]`.
   That throw propagated through storyboard synthesis, prepending a
   synthetic `synthesis_error` phase that broke every signed_requests
   storyboard test (structural completeness, response schema coverage,
   grader e2e, grader MCP, runner dispatch, synthesize step expansion).

2. Hardcoded vector counts (8 positive / 20 negative) in three test
   files fail when the cache ships the new vectors. Float the
   assertions to `>= 8` / `>= 20` so future cache growth doesn't
   regress the suite.

Scope of this fix:

- Vector loader parses `jwks_override` (keys array) alongside
  `jwks_ref` (kid[]). `NegativeVector` gains an optional
  `jwks_override` field; `jwks_ref` may be empty when override is
  present.
- Builder registers pass-through mutations for 021-026 so the storyboard
  runner can dispatch them (the vectors ship fully-formed malformed
  headers; no programmatic mutation needed).
- Vector test suite skips 021-026 in the negative conformance run with
  a reason (verifier hasn't been extended for duplicate sig-input
  labels, multi-valued content-type / content-digest, unquoted string
  params, JWK alg/crv consistency, or non-ASCII @authority detection
  yet — tracked as a follow-up to #631).
- Grader e2e and MCP tests skip 021-026 the same way.
- Test count assertions updated to floors (>=) across the loader,
  grader, synthesize-expansion, and MCP test files.

Result: 4222 pass / 0 fail / 13 skipped (7 pre-existing +
6 new 021-026 skips). Was 31 fail on main before this + the prior
canonicalization fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 20, 2026
)

* feat(signing): brand.json → JWKS auto-resolver (follow-up to #631)

Delivers the `brand.json → JWKS auto-resolver` item from PR #631's
follow-up list. Companion `createWebhookEmitter` (publisher side) and
`createAdcpServer` integration remain as future work.

## `BrandJsonJwksResolver`

Receiver-side ergonomic: instead of pre-configuring a `jwks_uri` per
counterparty, point the verifier at the sender's brand.json and the
resolver walks `agents[]`, extracts the right `jwks_uri`, and delegates
caching to `HttpsJwksResolver`. Plugs into `verifyWebhookSignature.jwks`
(or `verifyRequestSignature.jwks`) with no changes required to existing
verifier call sites.

## Behavior

- Follows `authoritative_location` and `house` redirect variants up to
  `maxRedirects` hops (default 3) with explicit loop detection.
- Structurally validates every redirect target (scheme, no userinfo,
  fragments stripped) before dispatch. The `house` string variant is
  gated on a bare-hostname regex so an attacker-supplied brand.json
  cannot inject userinfo or paths via the `https://${house}/…`
  interpolation.
- Honors the spec fallback to `/.well-known/jwks.json` on the agent
  origin when `jwks_uri` is absent — but only when that origin matches
  the final brand.json origin. Cross-origin fallback is rejected with
  `jwks_origin_mismatch`; publishers hosting their agent on a different
  origin must declare an explicit `jwks_uri`.
- Brand.json cache tracks ETag + Cache-Control max-age (capped by
  `maxAgeSeconds`, default 1h). Unknown kid cascades: the inner JWKS
  refreshes first; if still unknown and the brand.json cooldown has
  elapsed, brand.json re-resolves to pick up a rotated `jwks_uri`.
- Ambiguous selectors throw `agent_ambiguous` with candidate ids.
- All fetches go through `ssrfSafeFetch`, so an attacker-supplied
  brand.json or JWKS URL cannot reach the receiver's private network
  or IMDS.
- Typed `BrandJsonResolverError` with stable `code` enum lets verifier
  callers fold transient failures into `webhook_signature_key_unknown`
  without parsing error message strings.

## Test coverage

18 tests: flat brand, portfolio (house + brands, brand-over-house
precedence), authoritative_location redirect chain, jwks_uri fallback,
selector ambiguity, rotation across maxAge, 304 cache hit, end-to-end
webhook verify, cross-origin fallback rejection, malformed house
string, malformed URL, userinfo injection, private-IP refusal under
default posture, maxRedirects exceeded, schema-invalid agent.url,
non-JSON body.

Regenerate core/schemas/tools types for upstream schema drift
(plan_hash on audit log, push_notification authentication precedence
note that validates the resolver's role).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(signing): decode RFC 3986 unreserved percent-encoded chars in @target-uri

Per RFC 3986 §6.2.2.2 and the AdCP RFC 9421 profile canonicalization
step 6, percent-encoded triplets for unreserved characters
(ALPHA / DIGIT / "-" / "." / "_" / "~") MUST be decoded to their literal
form. Previously `canonicalTargetUri` only uppercased hex, so a signer
that already decoded unreserved chars and a verifier that didn't would
produce mismatched signature bases (e.g., `%7E` vs. `~` → signature
invalid at step 10).

Fixes positive vector 009-percent-encoded-unreserved-decoded.json
(present in compliance/cache/latest since the last schema sync) which
was failing on main against both the byte-equal canonicalization
assertion and the downstream verifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(signing): unblock CI after vector 021-026 drift from latest cache

The compliance cache now ships six new request-signing negative vectors
that didn't exist at PR #631 merge:

  021-duplicate-signature-input-label
  022-multi-valued-content-type
  023-multi-valued-content-digest
  024-unquoted-string-param
  025-jwk-alg-crv-mismatch (uses jwks_override instead of jwks_ref)
  026-non-ascii-host

Two load-bearing issues on main:

1. Vector 025 ships a deliberately-malformed JWK inline via
   `jwks_override` instead of referencing keys.json. The vector loader
   threw on parse because it only understood `jwks_ref: string[]`.
   That throw propagated through storyboard synthesis, prepending a
   synthetic `synthesis_error` phase that broke every signed_requests
   storyboard test (structural completeness, response schema coverage,
   grader e2e, grader MCP, runner dispatch, synthesize step expansion).

2. Hardcoded vector counts (8 positive / 20 negative) in three test
   files fail when the cache ships the new vectors. Float the
   assertions to `>= 8` / `>= 20` so future cache growth doesn't
   regress the suite.

Scope of this fix:

- Vector loader parses `jwks_override` (keys array) alongside
  `jwks_ref` (kid[]). `NegativeVector` gains an optional
  `jwks_override` field; `jwks_ref` may be empty when override is
  present.
- Builder registers pass-through mutations for 021-026 so the storyboard
  runner can dispatch them (the vectors ship fully-formed malformed
  headers; no programmatic mutation needed).
- Vector test suite skips 021-026 in the negative conformance run with
  a reason (verifier hasn't been extended for duplicate sig-input
  labels, multi-valued content-type / content-digest, unquoted string
  params, JWK alg/crv consistency, or non-ASCII @authority detection
  yet — tracked as a follow-up to #631).
- Grader e2e and MCP tests skip 021-026 the same way.
- Test count assertions updated to floors (>=) across the loader,
  grader, synthesize-expansion, and MCP test files.

Result: 4222 pass / 0 fail / 13 skipped (7 pre-existing +
6 new 021-026 skips). Was 31 fail on main before this + the prior
canonicalization fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Apr 20, 2026
bokelley added a commit that referenced this pull request Apr 20, 2026
Publisher-side webhook emission — the symmetric counterpart to PR #629's
receiver dedup. One ctx.emitWebhook(url, payload, operation_id) call and
the emitter handles:

- RFC 9421 signing with fresh nonce per attempt (adcp#2423).
- Stable idempotency_key per operation_id across retries (adcp#2417).
- JSON serialized once with compact separators and posted byte-identically
  (adcp#2478) — closes the Python json.dumps-default-spacing trap.
- Retry with exp backoff + jitter on 5xx/429. Terminal on 4xx and on 401
  responses carrying WWW-Authenticate: Signature error="webhook_signature_*".
- Pluggable WebhookIdempotencyKeyStore (default in-memory); swap in a
  durable backend for multi-replica publishers.
- HMAC-SHA256 / Bearer fallback modes for legacy buyers.

createAdcpServer integration: new webhooks? config option. When set,
ctx.emitWebhook is populated on every handler's context — completion
handlers post signed webhooks without constructing the signer, fetching,
or tracking idempotency themselves.

Tests:
- 14 unit tests covering every delivery path (happy / retry / terminal /
  401+Signature / cap / cross-call stability / HMAC + Bearer fallback /
  observability).
- 3 full-stack E2E tests: createAdcpServer → ctx.emitWebhook → real HTTP
  → receiver captures → verifyWebhookSignature accepts. No mocks on the
  signer/verifier path. Closes the "haven't spun up an actual server"
  gap flagged during PR #631 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 20, 2026
…ation (#632)

* feat(server): createWebhookEmitter + createAdcpServer integration

Publisher-side webhook emission — the symmetric counterpart to PR #629's
receiver dedup. One ctx.emitWebhook(url, payload, operation_id) call and
the emitter handles:

- RFC 9421 signing with fresh nonce per attempt (adcp#2423).
- Stable idempotency_key per operation_id across retries (adcp#2417).
- JSON serialized once with compact separators and posted byte-identically
  (adcp#2478) — closes the Python json.dumps-default-spacing trap.
- Retry with exp backoff + jitter on 5xx/429. Terminal on 4xx and on 401
  responses carrying WWW-Authenticate: Signature error="webhook_signature_*".
- Pluggable WebhookIdempotencyKeyStore (default in-memory); swap in a
  durable backend for multi-replica publishers.
- HMAC-SHA256 / Bearer fallback modes for legacy buyers.

createAdcpServer integration: new webhooks? config option. When set,
ctx.emitWebhook is populated on every handler's context — completion
handlers post signed webhooks without constructing the signer, fetching,
or tracking idempotency themselves.

Tests:
- 14 unit tests covering every delivery path (happy / retry / terminal /
  401+Signature / cap / cross-call stability / HMAC + Bearer fallback /
  observability).
- 3 full-stack E2E tests: createAdcpServer → ctx.emitWebhook → real HTTP
  → receiver captures → verifyWebhookSignature accepts. No mocks on the
  signer/verifier path. Closes the "haven't spun up an actual server"
  gap flagged during PR #631 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scripts): agent-skill-storyboard dogfood harness

Non-interactive Claude Code harness that answers "can an agent with our
SDK + a build-*-agent skill actually stand up a server that passes a
compliance storyboard?"

Flow:
  1. Pre-populates a tmp workspace with package.json pointing @adcp/client
     at the repo root (file:<repo>), runs npm install upfront so Claude
     never touches deps.
  2. Invokes claude -p <prompt> --dangerously-skip-permissions with the
     skill content + target storyboard + fixed port embedded in the prompt.
  3. After Claude exits cleanly, runs the start.sh it produced, waits for
     the port, invokes bin/adcp.js storyboard run <url> <id> --json,
     reports pass/fail, tears down the spawned server + workspace.

Usage:
  npm run compliance:agent-skill -- \
    --skill skills/build-seller-agent/SKILL.md \
    --storyboard universal/idempotency

Clean boundaries: Claude is a code-writer, harness is the orchestrator —
keeps failure modes diagnosable (was it the skill guidance, the SDK, the
grader, or the agent itself).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scripts): agent-skill harness — --allow-http + diagnostic pass

First live run surfaced two plumbing issues beyond the Claude prompt:

1. The compliance runner hard-refuses http:// URLs (production agents
   MUST terminate TLS). Since the harness always binds on loopback, it
   passes --allow-http explicitly.

2. The prompt's "verify the server starts, then kill it" step taught
   Claude to leave a process on port 4200 when the harness tried to
   start its own. Tightened the instruction to "do NOT run the server,
   not even to verify" and added defense-in-depth port-cleanup in the
   harness (lsof | kill -9) before bash start.sh.

Validated end-to-end: Claude builds server.ts + start.sh, harness starts
it, grader runs against it, storyboard reports real conformance results
(1 partial pass on `idempotency` — ACCOUNT_NOT_FOUND and missing-key
rejection are both skill-guidance gaps the build-seller-agent skill can
be tightened against).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(skill): tighten build-seller-agent for compliance grader defaults

First live agent-skill-storyboard harness run surfaced that the skill's
resolveAccount example only handled { account_id } references — the
idempotency conformance grader (and any other storyboard using the
canonical { brand: { domain }, operator } shape) short-circuits with
ACCOUNT_NOT_FOUND before the idempotency layer ever runs. That masked
real conformance signal behind the wrong error code.

Changes:

1. Example resolveAccount now handles BOTH AccountReference branches.
   Includes a dev-mode wildcard for brand+operator so compliance graders
   reach the handler, with an explicit note to replace with a real
   tenant lookup in production.

2. Idempotency section now calls out the ordering gotcha: resolveAccount
   runs BEFORE idempotency, so null-returning resolveAccount surfaces as
   ACCOUNT_NOT_FOUND rather than the missing-key/replay error the spec
   actually wants. Tells readers what to fix when their storyboard runs
   fail with unexpected error codes.

Also: prettier-format the skill file (CI check).

* chore(emitter): drop unused defaultRetries (CodeQL review)

Retries are re-resolved per-emit inside `emit()` (to honor per-call
overrides), so the factory-level `defaultRetries` was dead. CodeQL
flagged it on PR review.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 21, 2026
…1-027 (#687)

* feat(signing): implement verifier conformance for negative vectors 021-027

Closes #683. The RFC 9421 reference verifier now enforces the full
#verifier-checklist-requests + #webhook-security profile against the seven
negative conformance vectors that had been skipped since #631.

Vectors 021-026 were already implemented library-side (duplicate
Signature-Input labels, multi-valued content-type / content-digest,
unquoted string params, JWK alg/crv consistency, non-ASCII @authority);
the conformance suite was skipping them via `NEGATIVE_VECTORS_UNIMPLEMENTED`.
Removing that skip-list surfaced one harness bug: the test runner's
eager `canonicalTargetUri(vector.request.url)` threw on vector 026's
U-label authority before the verifier's own parse-time check could fire.
Deferred the precompute until a `replay_cache_entries` preload actually
needs it.

Vector 027 (webhook-authentication downgrade resistance) required new
verifier logic and builder registration:

- `verifyRequestSignature` on the unsigned branch now rejects any request
  whose JSON body carries a non-empty `push_notification_config.authentication`
  anywhere in the tree — inside nested arrays, under any parent key, and
  regardless of `Content-Type`. Closes the captured-bearer-token ->
  hostile webhook callback redirect path; `required_for` precedence is
  preserved so an operation already on the always-sign list still gets
  the more specific error message.
- DoS hardened: body size capped at 1 MB (oversized unsigned bodies fail
  closed with `request_signature_required` since we can't prove absence
  of webhook auth within the pre-crypto budget), traversal capped at
  depth 64 so pathologically nested JSON can't blow the stack.
- Builder registers 027 as a passthrough mutator since the adversarial
  shape lives in the fixture body, not a programmatic mutation.

Test coverage: nine new unit tests in `request-signing-verifier-api.test.js`
lock the surface around the conformance vector — present-without-auth,
empty auth, null auth, string-typed auth, array-nested auth, non-JSON
body, oversized body, and signed requests carrying auth material (must
reach the crypto path, not re-reject at step 0). Grader e2e + MCP tests
drop the upstream-added `UNIMPLEMENTED_VERIFIER_RULE_VECTORS` skip
introduced in #682; full suite 4502 pass / 0 fail / 6 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: prettier on new webhook-auth verifier tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: trigger CI on formatting-fixed head

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Apr 21, 2026
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.

2 participants