feat(storyboard): outbound-webhook conformance runner (#2426) + 9421 webhook verifier#631
Merged
Conversation
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>
- 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
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>
4 tasks
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>
Merged
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>
3 tasks
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>
This was referenced Apr 20, 2026
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/runStoryboardStepaccept awebhook_receiveroption that binds an ephemeral HTTP listener. Two endpoint modes:loopback_mock(default, loopback-only, zero setup) andproxy_url(operator-supplied public base, for AdCP Verified grading against remote agents)./step/<step_id>/<operation_id>and exposes{{runner.webhook_base}}/{{runner.webhook_url:<step_id>}}substitutions so storyboards can inject them intopush_notification_config.url. Downstream filters pick up the same operation_id via{{prior_step.<step_id>.operation_id}}.RFC 9421 webhook verifier
Hardening (from expert review)
Security + protocol + code reviewers approved; feedback fully incorporated in commit `f8f865d6`:
Conformance vector harness (adcp#2445)
Upstream interaction
Filed or surfaced three upstream drifts while building this:
End-to-end
Test plan
Follow-up PRs
Changesets