From ff00fb03cef565e09de3553b2162ffe8a676fe6c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 21 Apr 2026 11:06:34 -0400 Subject: [PATCH 1/3] fix(storyboard): refs_resolve follow-ups (#710, #711, #712, #714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle four refs_resolve validator fixes surfaced during adcp#2601 verification: - #710 Canonicalize `$agent_url` by stripping transport suffixes (/mcp, /a2a, /sse, /.well-known/agent[-card].json) while preserving subpath so sibling agents on shared hosts stay distinguishable. Mirrors the SDK's computeBaseUrl. Previously the check was a silent no-op on MCP/A2A agents because the runner's target URL never matched a ref's bare agent URL. - #711 Emit `scope_excluded_all_refs` meta-observation when the scope filter partitions 100% of source refs out — catches silent no-ops without changing pass/fail semantics. Suppressed under `on_out_of_scope: 'ignore'`. - #712 Detect paginated current-step targets and demote unresolved refs to `unresolved_with_pagination` observations instead of failing the check. Stops false-positives against conformant paginating sellers until the spec-level "compliance mode returns all" rule lands. - #714 Harden grader-visible payloads against hostile agent responses: strip userinfo (URL parse + regex fallback for non-URL fields), reject non-http schemes, code-point-safe truncate at 512 chars, cap observations at 50 with truncation marker, use hasOwnProperty throughout. Tests cover each fix plus the security hardening cases flagged in review (surrogate-pair safety, prototype-key guarding, meta-observation ordering under the cap, context-vs-current-step pagination scoping, ignore-mode suppression). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../refs-resolve-canonicalize-agent-url.md | 21 + .../refs-resolve-observation-hygiene.md | 31 ++ .../refs-resolve-scope-excluded-warning.md | 10 + .changeset/refs-resolve-target-paginated.md | 15 + src/lib/testing/storyboard/types.ts | 7 +- src/lib/testing/storyboard/validations.ts | 213 +++++++- src/lib/version.ts | 6 +- ...toryboard-validations-refs-resolve.test.js | 480 ++++++++++++++++++ 8 files changed, 764 insertions(+), 19 deletions(-) create mode 100644 .changeset/refs-resolve-canonicalize-agent-url.md create mode 100644 .changeset/refs-resolve-observation-hygiene.md create mode 100644 .changeset/refs-resolve-scope-excluded-warning.md create mode 100644 .changeset/refs-resolve-target-paginated.md diff --git a/.changeset/refs-resolve-canonicalize-agent-url.md b/.changeset/refs-resolve-canonicalize-agent-url.md new file mode 100644 index 000000000..9def173fd --- /dev/null +++ b/.changeset/refs-resolve-canonicalize-agent-url.md @@ -0,0 +1,21 @@ +--- +'@adcp/client': patch +--- + +`refs_resolve` scope: canonicalize `$agent_url` by stripping transport +suffixes instead of comparing raw target URL to bare agent origins. + +Before this fix, storyboards using `scope: { key: 'agent_url', equals: +'$agent_url' }` silently graded every source ref `out_of_scope` on MCP +and A2A runners, because `$agent_url` expanded to the runner's target +URL (with `/mcp`, `/a2a`, or `/.well-known/agent.json` suffixes) while +refs carried the bare agent URL per AdCP convention. Net effect: the +check degraded from integrity enforcement to a no-op on every MCP agent. + +The scope comparator now mirrors `SingleAgentClient.computeBaseUrl`: +strip `/mcp`, `/a2a`, `/sse`, and `/.well-known/agent[-card].json` +suffixes; lowercase scheme and host; drop default ports; strip +userinfo, query, and fragment. Path below the transport suffix is +preserved, so sibling agents at different subpaths on a shared host +(e.g. `https://publisher.com/.well-known/adcp/sales` vs +`/.well-known/adcp/creative`) remain distinguishable. Closes #710. diff --git a/.changeset/refs-resolve-observation-hygiene.md b/.changeset/refs-resolve-observation-hygiene.md new file mode 100644 index 000000000..5c891d81a --- /dev/null +++ b/.changeset/refs-resolve-observation-hygiene.md @@ -0,0 +1,31 @@ +--- +'@adcp/client': patch +--- + +`refs_resolve`: harden grader-visible observation and `actual.missing` +payloads against hostile agent responses. + +Compliance reports may be published or forwarded to third parties, so +every ref field emitted by the runner is now: + +- **Userinfo-scrubbed** on URL-keyed fields via WHATWG URL parsing plus + a regex fallback that scrubs `scheme://user:pass@` shapes embedded + in non-URL fields. Credentials planted in `agent_url` values can no + longer leak through compliance output. +- **Scheme-restricted** on URL-keyed fields: non-`http(s)` schemes + (e.g. `javascript:`, `data:`, `file:`) are replaced with a + `` placeholder so downstream UIs rendering + `agent_url` as a link cannot inherit a stored-XSS vector. +- **Length-capped** at 512 code points per string field, with a + code-point-boundary truncation that preserves surrogate pairs. +- **Count-capped** at 50 observations per check, with an + `observations_truncated` marker when the cap fires. Meta + observations (`scope_excluded_all_refs`, `target_paginated`) + precede per-ref entries so the cap never drops primary signal. + +Match and dedup behavior is unchanged: the internal projection used +for ref comparison is kept separate from the sanitized projection used +for user-facing output, so truncation never false-collapses dedup +keys. `refsMatch` and `projectRef` also now use `hasOwnProperty` to +prevent storyboard authors from accidentally drawing match keys from +`Object.prototype`. Closes #714. diff --git a/.changeset/refs-resolve-scope-excluded-warning.md b/.changeset/refs-resolve-scope-excluded-warning.md new file mode 100644 index 000000000..402fc64b8 --- /dev/null +++ b/.changeset/refs-resolve-scope-excluded-warning.md @@ -0,0 +1,10 @@ +--- +'@adcp/client': patch +--- + +`refs_resolve`: emit a `scope_excluded_all_refs` meta-observation when +a scope filter partitions every source ref out. The integrity check +enforces nothing when no ref falls in-scope; graders previously got a +silent pass. The meta-observation surfaces the structural smell without +changing pass/fail semantics. Suppressed under `on_out_of_scope: 'ignore'` +(which explicitly opts out of scope warnings). Closes #711. diff --git a/.changeset/refs-resolve-target-paginated.md b/.changeset/refs-resolve-target-paginated.md new file mode 100644 index 000000000..7b3555c9e --- /dev/null +++ b/.changeset/refs-resolve-target-paginated.md @@ -0,0 +1,15 @@ +--- +'@adcp/client': patch +--- + +`refs_resolve`: detect paginated current-step targets and demote +unresolved refs to observations instead of failing the check. + +Previously, when the target response carried `pagination.has_more: +true`, any ref legitimately defined on a later page graded as +`missing` — a false-positive failure against a conformant paginating +seller. The runner now emits a `target_paginated` meta-observation and +reports each would-be-missing ref as an `unresolved_with_pagination` +observation, letting the check pass until the spec-level resolution +lands (compliance mode requiring sellers to return everything +referenced by products in a single response). Closes #712. diff --git a/src/lib/testing/storyboard/types.ts b/src/lib/testing/storyboard/types.ts index 4c30c9901..9f297e92a 100644 --- a/src/lib/testing/storyboard/types.ts +++ b/src/lib/testing/storyboard/types.ts @@ -276,9 +276,10 @@ export interface RefsResolveSet { } /** - * Scope filter for `refs_resolve`. Only source refs whose `key` equals - * (after `$agent_url` substitution + `normalizeAgentUrl` normalization) - * are in scope. Out-of-scope refs are graded by `on_out_of_scope`. + * Scope filter for `refs_resolve`. Only source refs whose `key` matches + * `equals` (after `$agent_url` substitution and, for URL-ending keys, + * transport-suffix-stripping canonicalization) are in scope. Out-of-scope + * refs are graded by `on_out_of_scope`. */ export interface RefsResolveScope { key: string; diff --git a/src/lib/testing/storyboard/validations.ts b/src/lib/testing/storyboard/validations.ts index 88012f77c..925087ff7 100644 --- a/src/lib/testing/storyboard/validations.ts +++ b/src/lib/testing/storyboard/validations.ts @@ -685,11 +685,21 @@ function validateOn401RequireHeader(validation: StoryboardValidation, hr: HttpPr // resource_equals_agent_url // ──────────────────────────────────────────────────────────── +/** + * Normalize a URL for RFC 9728 resource identity checks: + * lowercases scheme/host, strips userinfo, query, and fragment, and + * collapses trailing slashes while preserving the path. RFC 9728 + * `resource` identifies the agent at its full endpoint, so paths are + * significant here — use `canonicalizeAgentUrlForScope` for `refs_resolve` + * scope comparisons where paths must be dropped. + */ function normalizeAgentUrl(url: string): string { try { const u = new URL(url); u.hash = ''; u.search = ''; + u.username = ''; + u.password = ''; // Drop trailing slash but keep the root "/". if (u.pathname.length > 1 && u.pathname.endsWith('/')) { u.pathname = u.pathname.slice(0, -1); @@ -700,6 +710,48 @@ function normalizeAgentUrl(url: string): string { } } +/** + * Canonical form of an agent URL for `refs_resolve` scope comparisons. + * + * AdCP's `agent_url` identifies the agent — which may live under a subpath + * like `https://publisher.com/.well-known/adcp/sales` (see core/format-id.json). + * Path is therefore significant and MUST be preserved; collapsing to origin + * would false-positive sibling agents on shared hosts. + * + * What must be stripped is the transport suffix the runner appends to reach + * the protocol endpoint (`/mcp`, `/a2a`, `/sse`) and the well-known agent-card + * path, so the runner's target URL canonicalizes to the same value the agent + * advertises in its refs. Mirrors `SingleAgentClient.computeBaseUrl` but as a + * pure string operation suitable for validation without an agent instance. + * + * Also: lowercase scheme/host, drop default ports, strip userinfo, query, + * fragment — normal RFC 3986 §6.2 canonicalization. Closes adcp-client#710. + */ +const TRANSPORT_SUFFIX_RE = /\/(?:mcp|a2a|sse)\/?$/i; +const AGENT_CARD_SUFFIX_RE = /\/\.well-known\/agent(?:-card)?\.json$/i; +function canonicalizeAgentUrlForScope(url: string): string { + try { + const u = new URL(url); + u.hash = ''; + u.search = ''; + u.username = ''; + u.password = ''; + let path = u.pathname; + path = path.replace(AGENT_CARD_SUFFIX_RE, ''); + path = path.replace(TRANSPORT_SUFFIX_RE, ''); + // A bare `/` and the empty string denote the same origin-relative root; + // collapse them so `https://host` and `https://host/` canonicalize equally. + if (path === '/' || path === '') path = ''; + else if (path.endsWith('/')) path = path.slice(0, -1); + const defaultPort = u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : ''; + const host = u.hostname.toLowerCase(); + const port = u.port && u.port !== defaultPort ? `:${u.port}` : ''; + return `${u.protocol.toLowerCase()}//${host}${port}${path}`; + } catch { + return url; + } +} + function validateResourceEqualsAgentUrl( validation: StoryboardValidation, hr: HttpProbeResult, @@ -829,12 +881,25 @@ function validateRefsResolve(validation: StoryboardValidation, ctx: ValidationCo const sourceRefs = sourceRaw.filter(isRefObject); const targetRefs = targetRaw.filter(isRefObject); - const scalarObservations: Array<{ kind: string; side: 'source' | 'target'; count: number }> = []; + const metaObservations: Array> = []; if (sourceRaw.length > 0 && sourceRefs.length === 0) { - scalarObservations.push({ kind: 'non_object_values_filtered', side: 'source', count: sourceRaw.length }); + metaObservations.push({ kind: 'non_object_values_filtered', side: 'source', count: sourceRaw.length }); } if (targetRaw.length > 0 && targetRefs.length === 0) { - scalarObservations.push({ kind: 'non_object_values_filtered', side: 'target', count: targetRaw.length }); + metaObservations.push({ kind: 'non_object_values_filtered', side: 'target', count: targetRaw.length }); + } + + // adcp-client#712: when the current-step target response carries + // pagination metadata with more pages available, the target set is + // incomplete and missing refs may legitimately live on later pages. + // Surface a meta-observation AND demote unresolved refs to + // observations so partial-page reads don't false-fail the check. + // The stricter fix (spec Option A: "compliance mode returns everything + // referenced by products") needs an AdCP spec change — until that + // lands, the runner must not penalize conformant paginating sellers. + const targetPaginated = target.from === 'current_step' && hasMorePages(targetRoot); + if (targetPaginated) { + metaObservations.push({ kind: 'target_paginated', side: 'target' }); } const scope = validation.scope; @@ -858,11 +923,31 @@ function validateRefsResolve(validation: StoryboardValidation, ctx: ValidationCo } } - const missing = dedupRefs( + // adcp-client#711: catch the silent-no-op case where a scope filter + // excludes 100% of source refs. Independent of whether the partition + // is "correct" — if every ref falls out of scope, the check enforces + // nothing and the grader deserves to see that structural smell. + // Suppressed under `on_out_of_scope: 'ignore'` because that mode + // explicitly opts out of scope-related warnings. + if (scope && outOfScopeMode !== 'ignore' && sourceRefs.length > 0 && inScope.length === 0) { + metaObservations.push({ + kind: 'scope_excluded_all_refs', + count: sourceRefs.length, + scope_key: scope.key, + }); + } + + const unresolved = dedupRefs( inScope.filter(s => !targetRefs.some(t => refsMatch(s, t, matchKeys))), matchKeys ); + // When the target was paginated and refs are unresolved, demote those + // refs from `missing` to `unresolved_with_pagination` observations so a + // conformant paginating seller passes. The meta-observation still fires. + const missing = targetPaginated ? [] : unresolved; + const paginatedUnresolved = targetPaginated ? unresolved : []; + // `fail` mode promotes out-of-scope refs into the missing set so compliance // reports name them the same way they name truly broken refs. const failOutOfScope = outOfScopeMode === 'fail' ? dedupRefs(outOfScope, matchKeys) : []; @@ -870,10 +955,19 @@ function validateRefsResolve(validation: StoryboardValidation, ctx: ValidationCo const warnObservations = outOfScopeMode === 'warn' && outOfScope.length > 0 - ? dedupRefs(outOfScope, matchKeys).map(ref => ({ kind: 'out_of_scope_ref', ref: projectRef(ref, matchKeys) })) + ? dedupRefs(outOfScope, matchKeys).map(ref => ({ + kind: 'out_of_scope_ref', + ref: projectRefForReport(ref, matchKeys), + })) : []; - const observations = - warnObservations.length + scalarObservations.length > 0 ? [...warnObservations, ...scalarObservations] : undefined; + const paginatedObservations = paginatedUnresolved.map(ref => ({ + kind: 'unresolved_with_pagination', + ref: projectRefForReport(ref, matchKeys), + })); + // Meta-observations precede per-ref observations so the array cap never + // drops the grader-signal primitives (scope_excluded_all_refs, + // target_paginated) in favor of redundant out-of-scope entries. + const observations = capObservations([...metaObservations, ...paginatedObservations, ...warnObservations]); if (allMissing.length === 0) { return { @@ -886,7 +980,7 @@ function validateRefsResolve(validation: StoryboardValidation, ctx: ValidationCo const preview = allMissing .slice(0, 3) - .map(r => JSON.stringify(projectRef(r, matchKeys))) + .map(r => JSON.stringify(projectRefForReport(r, matchKeys))) .join(', '); const errorMsg = allMissing.length > 3 @@ -902,15 +996,27 @@ function validateRefsResolve(validation: StoryboardValidation, ctx: ValidationCo json_pointer: null, expected: `every source ref resolves to a target ref matched on [${matchKeys.join(', ')}]`, actual: { - missing: allMissing.map(r => projectRef(r, matchKeys)), + missing: allMissing.map(r => projectRefForReport(r, matchKeys)), ...(failOutOfScope.length > 0 && { - out_of_scope_failed: failOutOfScope.map(r => projectRef(r, matchKeys)), + out_of_scope_failed: failOutOfScope.map(r => projectRefForReport(r, matchKeys)), }), }, ...(observations && { observations }), }; } +/** + * Detect whether the response at `root` advertises additional pages. + * Accepts the AdCP-standard `pagination.has_more` flag; conservative + * otherwise (false on non-objects, missing fields, or unparseable shapes). + */ +function hasMorePages(root: unknown): boolean { + if (!root || typeof root !== 'object' || Array.isArray(root)) return false; + const pagination = (root as Record).pagination; + if (!pagination || typeof pagination !== 'object' || Array.isArray(pagination)) return false; + return (pagination as Record).has_more === true; +} + function resolveRefsRoot(from: 'current_step' | 'context', ctx: ValidationContext): unknown { if (from === 'context') return ctx.storyboardContext ?? {}; if (ctx.taskResult) return ctx.taskResult.data; @@ -920,11 +1026,11 @@ function resolveRefsRoot(from: 'current_step' | 'context', ctx: ValidationContex function resolveScopeEquals(equals: string, key: string, agentUrl: string): string { const raw = equals === '$agent_url' ? agentUrl : equals; - return key.toLowerCase().endsWith('url') ? normalizeAgentUrl(raw) : raw; + return key.toLowerCase().endsWith('url') ? canonicalizeAgentUrlForScope(raw) : raw; } function normalizeIfUrlKey(value: unknown, key: string): unknown { - return typeof value === 'string' && key.toLowerCase().endsWith('url') ? normalizeAgentUrl(value) : value; + return typeof value === 'string' && key.toLowerCase().endsWith('url') ? canonicalizeAgentUrlForScope(value) : value; } function isRefObject(value: unknown): value is Record { @@ -933,6 +1039,11 @@ function isRefObject(value: unknown): value is Record { function refsMatch(a: Record, b: Record, keys: string[]): boolean { for (const key of keys) { + // Own-property only: a storyboard author supplying `match_keys: ['constructor']` + // must not match every object via prototype chain. + if (!Object.prototype.hasOwnProperty.call(a, key) || !Object.prototype.hasOwnProperty.call(b, key)) { + return false; + } const av = a[key]; const bv = b[key]; // Missing match-key on either side is NOT a match — agents that omit a @@ -951,11 +1062,87 @@ function refsMatch(a: Record, b: Record, keys: function projectRef(ref: Record, keys: string[]): Record { const out: Record = {}; for (const key of keys) { - if (key in ref) out[key] = ref[key]; + if (Object.prototype.hasOwnProperty.call(ref, key)) out[key] = ref[key]; } return out; } +// Observation hygiene caps (adcp-client#714). Compliance reports may be +// published or forwarded to third parties, so every ref field emitted in +// observations / actual.missing is length-bounded and URL fields have +// credentials scrubbed before they leave the runner. +const REF_FIELD_MAX_LEN = 512; +const MAX_OBSERVATIONS = 50; + +/** + * Projection of a ref intended for grader-visible output (observations, + * `actual.missing`). Strips userinfo from URL fields, caps string length, + * and passes non-string values through untouched. Kept distinct from the + * internal `projectRef` because dedup relies on stable JSON projection — + * truncating strings would false-collapse refs that differ only in their + * truncated suffix. + */ +function projectRefForReport(ref: Record, keys: string[]): Record { + const out: Record = {}; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(ref, key)) continue; + const v = ref[key]; + if (typeof v === 'string') { + out[key] = sanitizeFieldString(v, key.toLowerCase().endsWith('url')); + } else { + out[key] = v; + } + } + return out; +} + +// Regex fallback that scrubs `scheme://user:pass@host` credential shapes +// in free-text fields — covers cases where `new URL()` can't parse the value +// (partial URL, extra prose, trailing whitespace) but a user:pass@ substring +// still leaks credentials to grader reports. +const CREDENTIAL_SHAPE_RE = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\s@]+@/g; + +function sanitizeFieldString(value: string, isUrlField: boolean): string { + // Truncate first so a multi-MB hostile string doesn't force URL parsing + // over the whole payload. Truncation uses code points so surrogate pairs + // aren't cleaved into invalid UTF-16 strings. + let out = value; + if (out.length > REF_FIELD_MAX_LEN) { + out = Array.from(out).slice(0, REF_FIELD_MAX_LEN - 1).join('') + '…'; + } + if (isUrlField) { + try { + const u = new URL(out); + // Reject dangerous schemes in URL-keyed fields: downstream compliance + // UIs that render `agent_url` as a clickable link otherwise inherit + // `javascript:` / `data:` / `file:` as a stored-XSS vector. + if (u.protocol !== 'https:' && u.protocol !== 'http:') { + return ``; + } + if (u.username || u.password) { + u.username = ''; + u.password = ''; + out = u.toString(); + } + } catch { + // Not a parseable URL — fall through to the regex scrub below. + } + } + // Belt-and-suspenders: strip any remaining `scheme://user:pass@` shapes, + // whether or not the field is URL-keyed. A hostile agent may plant + // credential-shaped substrings inside an `id` or a non-URL ref field. + out = out.replace(CREDENTIAL_SHAPE_RE, '$1'); + return out; +} + +function capObservations(observations: Array>): Array> | undefined { + if (observations.length === 0) return undefined; + if (observations.length <= MAX_OBSERVATIONS) return observations; + const kept = observations.slice(0, MAX_OBSERVATIONS - 1); + kept.push({ kind: 'observations_truncated', dropped: observations.length - kept.length }); + return kept; +} + /** * Deduplicate refs by their projected match-key tuple so a single broken * reference in a 50-product response doesn't show up 50× in `actual.missing`. diff --git a/src/lib/version.ts b/src/lib/version.ts index 1be469f06..ad9965751 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -4,7 +4,7 @@ /** * AdCP client library version */ -export const LIBRARY_VERSION = '5.7.0'; +export const LIBRARY_VERSION = '5.8.0'; /** * AdCP specification version this library is built for @@ -27,10 +27,10 @@ export const COMPATIBLE_ADCP_VERSIONS = ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', ' * Full version information */ export const VERSION_INFO = { - library: '5.7.0', + library: '5.8.0', adcp: 'latest', compatibleVersions: COMPATIBLE_ADCP_VERSIONS, - generatedAt: '2026-04-20T23:27:56.706Z', + generatedAt: '2026-04-21T14:50:51.424Z', } as const; /** diff --git a/test/lib/storyboard-validations-refs-resolve.test.js b/test/lib/storyboard-validations-refs-resolve.test.js index 1e8b25a77..e466fea22 100644 --- a/test/lib/storyboard-validations-refs-resolve.test.js +++ b/test/lib/storyboard-validations-refs-resolve.test.js @@ -372,4 +372,484 @@ describe('validateRefsResolve', () => { // the point is that the runner survived. assert.strictEqual(typeof result.passed, 'boolean'); }); + + // ────────────────────────────────────────────────────────── + // #710: $agent_url canonicalization drops transport suffix + // ────────────────────────────────────────────────────────── + + it('canonicalizes $agent_url to origin so /mcp transport suffix does not mismatch', () => { + // Runner target URL carries /mcp; refs advertise bare origin. Both + // sides must collapse to https://sales.example.com before compare. + const BARE = 'https://sales.example.com'; + const context = { + products: [ + { + format_ids: [ + { agent_url: BARE, id: 'display_300x250' }, + { agent_url: BARE, id: 'video_pre_roll' }, + ], + }, + ], + }; + const taskResult = { + success: true, + data: { + formats: [ + { format_id: { agent_url: BARE, id: 'display_300x250' } }, + { format_id: { agent_url: BARE, id: 'video_pre_roll' } }, + ], + }, + }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'fail', + }), + taskResult, + context + ); + assert.strictEqual(result.passed, true, result.error); + }); + + it('canonicalizes /a2a and trailing-slash transport paths identically', () => { + const BARE = 'https://sales.example.com'; + const context = { + products: [{ format_ids: [{ agent_url: BARE, id: 'a' }] }], + }; + const taskResult = { + success: true, + data: { formats: [{ format_id: { agent_url: BARE, id: 'a' } }] }, + }; + // Agent URL with /a2a/ transport suffix and trailing slash. + const [result] = runValidations( + [refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' }, on_out_of_scope: 'fail' })], + { + taskName: 'list_creative_formats', + taskResult, + agentUrl: 'https://sales.example.com/a2a/', + contributions: new Set(), + storyboardContext: context, + } + ); + assert.strictEqual(result.passed, true, result.error); + }); + + it('canonicalizes default port and host case identically', () => { + const context = { + products: [{ format_ids: [{ agent_url: 'https://sales.example.com:443', id: 'a' }] }], + }; + const taskResult = { + success: true, + data: { formats: [{ format_id: { agent_url: 'HTTPS://Sales.Example.com/mcp', id: 'a' } }] }, + }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'fail', + }), + taskResult, + context + ); + assert.strictEqual(result.passed, true, result.error); + }); + + it('preserves subpath so sibling agents on a shared host do NOT collide', () => { + // Per AdCP core/format-id.json, agent_url can legitimately live under a + // subpath (e.g. https://publisher.com/.well-known/adcp/sales). Origin-only + // canonicalization would false-positive two siblings on the same host; + // path-preserving + transport-stripping must keep them distinct. + const SIBLING_A = 'https://publisher.com/.well-known/adcp/sales'; + const SIBLING_B = 'https://publisher.com/.well-known/adcp/creative'; + const context = { + products: [{ format_ids: [{ agent_url: SIBLING_B, id: 'x' }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + // Runner points at sibling A with /mcp transport; sibling B's refs must + // be classified out-of-scope, not in-scope. + const [result] = runValidations( + [refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' }, on_out_of_scope: 'warn' })], + { + taskName: 'list_creative_formats', + taskResult, + agentUrl: SIBLING_A + '/mcp', + contributions: new Set(), + storyboardContext: context, + } + ); + assert.strictEqual(result.passed, true); + const outOfScope = (result.observations ?? []).find(o => o.kind === 'out_of_scope_ref'); + assert.ok(outOfScope, 'sibling on same host must classify out_of_scope'); + assert.strictEqual(outOfScope.ref.agent_url, SIBLING_B); + }); + + it('strips .well-known agent card path from canonical form', () => { + const BARE = 'https://sales.example.com'; + const context = { + products: [{ format_ids: [{ agent_url: BARE, id: 'a' }] }], + }; + const taskResult = { + success: true, + data: { formats: [{ format_id: { agent_url: BARE, id: 'a' } }] }, + }; + const [result] = runValidations( + [refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' }, on_out_of_scope: 'fail' })], + { + taskName: 'list_creative_formats', + taskResult, + agentUrl: 'https://sales.example.com/.well-known/agent.json', + contributions: new Set(), + storyboardContext: context, + } + ); + assert.strictEqual(result.passed, true, result.error); + }); + + // ────────────────────────────────────────────────────────── + // #711: meta-warning when scope excludes 100% of refs + // ────────────────────────────────────────────────────────── + + it('emits scope_excluded_all_refs when scope filter partitions every source ref out', () => { + const context = { + products: [ + { + format_ids: [ + { agent_url: THIRD_PARTY_URL, id: 'third_1' }, + { agent_url: THIRD_PARTY_URL, id: 'third_2' }, + ], + }, + ], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'warn', + }), + taskResult, + context + ); + // Check passes (warn mode, nothing truly missing) but the meta-observation + // makes the silent-no-op visible to graders. + assert.strictEqual(result.passed, true, result.error); + const meta = (result.observations ?? []).find(o => o.kind === 'scope_excluded_all_refs'); + assert.ok(meta, 'expected scope_excluded_all_refs meta-observation'); + assert.strictEqual(meta.count, 2); + assert.strictEqual(meta.scope_key, 'agent_url'); + }); + + it('does not emit scope_excluded_all_refs when at least one ref is in scope', () => { + const context = { + products: [ + { + format_ids: [ + { agent_url: AGENT_URL, id: 'ok' }, + { agent_url: THIRD_PARTY_URL, id: 'third' }, + ], + }, + ], + }; + const taskResult = { + success: true, + data: { formats: [{ format_id: { agent_url: AGENT_URL, id: 'ok' } }] }, + }; + const [result] = runOne( + refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' } }), + taskResult, + context + ); + const meta = (result.observations ?? []).find(o => o.kind === 'scope_excluded_all_refs'); + assert.strictEqual(meta, undefined); + }); + + it('does not emit scope_excluded_all_refs when source is empty', () => { + const context = { products: [] }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' } }), + taskResult, + context + ); + assert.strictEqual(result.observations, undefined); + }); + + // ────────────────────────────────────────────────────────── + // #712: target_paginated observation + // ────────────────────────────────────────────────────────── + + it('emits target_paginated observation when current-step target has pagination.has_more', () => { + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: 'a' }] }], + }; + const taskResult = { + success: true, + data: { + formats: [{ format_id: { agent_url: AGENT_URL, id: 'a' } }], + pagination: { has_more: true, cursor: 'opaque' }, + }, + }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + assert.strictEqual(result.passed, true, result.error); + const meta = (result.observations ?? []).find(o => o.kind === 'target_paginated'); + assert.ok(meta, 'expected target_paginated observation'); + }); + + it('does not emit target_paginated when pagination.has_more is false', () => { + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: 'a' }] }], + }; + const taskResult = { + success: true, + data: { + formats: [{ format_id: { agent_url: AGENT_URL, id: 'a' } }], + pagination: { has_more: false }, + }, + }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + assert.strictEqual(result.observations, undefined); + }); + + it('demotes unresolved refs to observations when target is paginated (no false-fail)', () => { + // Seller paginates list_creative_formats; product references a format_id + // that legitimately lives on a later page. Must pass (observation only). + const context = { + products: [ + { + format_ids: [ + { agent_url: AGENT_URL, id: 'on_page_1' }, + { agent_url: AGENT_URL, id: 'on_page_2' }, + ], + }, + ], + }; + const taskResult = { + success: true, + data: { + formats: [{ format_id: { agent_url: AGENT_URL, id: 'on_page_1' } }], + pagination: { has_more: true, cursor: 'next' }, + }, + }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + assert.strictEqual(result.passed, true, 'paginated target must not false-fail'); + const demoted = (result.observations ?? []).find(o => o.kind === 'unresolved_with_pagination'); + assert.ok(demoted, 'expected unresolved_with_pagination observation for the page-2 ref'); + assert.deepStrictEqual(demoted.ref, { agent_url: AGENT_URL, id: 'on_page_2' }); + }); + + it('does NOT check pagination when target.from is context', () => { + // Pagination only applies to current_step reads. context targets are + // aggregated from prior steps and are treated as complete. + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: 'x' }] }], + formats: [], // <-- target read from context + pagination: { has_more: true }, // misplaced flag, must be ignored + }; + const taskResult = { success: true, data: {} }; + const [result] = runOne( + refsResolveCheck({ + target: { from: 'context', path: 'formats[*].format_id' }, + }), + taskResult, + context + ); + const meta = (result.observations ?? []).find(o => o.kind === 'target_paginated'); + assert.strictEqual(meta, undefined, 'target_paginated must not fire for context targets'); + }); + + it('suppresses scope_excluded_all_refs when on_out_of_scope is ignore', () => { + // Regression test for the deliberate gating: ignore mode explicitly + // opts out of scope-related warnings, so the meta-observation that + // catches silent-no-ops must not fire either. + const context = { + products: [{ format_ids: [{ agent_url: THIRD_PARTY_URL, id: 'z' }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'ignore', + }), + taskResult, + context + ); + assert.strictEqual(result.observations, undefined); + }); + + // ────────────────────────────────────────────────────────── + // #714: observations payload hygiene + // ────────────────────────────────────────────────────────── + + it('strips userinfo from agent_url in observations', () => { + const context = { + products: [ + { + format_ids: [{ agent_url: 'https://user:secret@attacker.example.com/mcp', id: 'x' }], + }, + ], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'warn', + }), + taskResult, + context + ); + const obs = (result.observations ?? []).find(o => o.kind === 'out_of_scope_ref'); + assert.ok(obs, 'expected out_of_scope_ref observation'); + assert.ok(!String(obs.ref.agent_url).includes('secret'), 'userinfo must be stripped'); + assert.ok(!String(obs.ref.agent_url).includes('user:'), 'userinfo must be stripped'); + }); + + it('truncates oversized ref string fields with ellipsis', () => { + const bigId = 'x'.repeat(2000); + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: bigId }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + assert.strictEqual(result.passed, false); + const missingId = result.actual.missing[0].id; + assert.ok(missingId.length <= 512, `id should be capped at 512 chars, was ${missingId.length}`); + assert.ok(missingId.endsWith('…'), 'truncated strings should end with ellipsis'); + }); + + it('caps observations array and emits observations_truncated marker', () => { + // 60 distinct out-of-scope refs; cap should hold at 50 with a truncation marker. + const manyRefs = []; + for (let i = 0; i < 60; i++) { + manyRefs.push({ agent_url: `https://other-${i}.example.com`, id: `f${i}` }); + } + const context = { products: [{ format_ids: manyRefs }] }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'warn', + }), + taskResult, + context + ); + assert.ok(result.observations.length <= 50, `capped at 50, got ${result.observations.length}`); + const truncation = result.observations.find(o => o.kind === 'observations_truncated'); + assert.ok(truncation, 'expected observations_truncated marker'); + assert.ok(truncation.dropped >= 10, `should report dropped count, got ${truncation.dropped}`); + }); + + it('preserves meta-observations when capping, with meta first', () => { + // Target has pagination, and >50 out-of-scope refs — meta observation + // must survive the cap AND sit at the front so a positional cap never + // drops the grader-signal primitives in favor of redundant entries. + const manyRefs = []; + for (let i = 0; i < 60; i++) { + manyRefs.push({ agent_url: `https://other-${i}.example.com`, id: `f${i}` }); + } + const context = { products: [{ format_ids: manyRefs }] }; + const taskResult = { + success: true, + data: { formats: [], pagination: { has_more: true } }, + }; + const [result] = runOne( + refsResolveCheck({ + scope: { key: 'agent_url', equals: '$agent_url' }, + on_out_of_scope: 'warn', + }), + taskResult, + context + ); + const kinds = result.observations.map(o => o.kind); + const pagIdx = kinds.indexOf('target_paginated'); + const scopeIdx = kinds.indexOf('scope_excluded_all_refs'); + const firstRef = kinds.indexOf('out_of_scope_ref'); + assert.ok(pagIdx !== -1, 'target_paginated must be preserved'); + assert.ok(scopeIdx !== -1, 'scope_excluded_all_refs must be preserved'); + assert.ok( + pagIdx < firstRef && scopeIdx < firstRef, + 'meta-observations must precede per-ref observations' + ); + }); + + // ────────────────────────────────────────────────────────── + // Hardening: security review follow-ups + // ────────────────────────────────────────────────────────── + + it('strips userinfo even from credential-shaped substrings in non-URL fields', () => { + // An id containing a scheme://user:pass@host shape should have creds + // scrubbed too — the URL parser short-circuits on strings like this + // because the id field is not URL-keyed, so belt-and-suspenders applies. + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: 'see https://user:token@evil.com/p for info' }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + const missingId = result.actual.missing[0].id; + assert.ok(!missingId.includes('user:token@'), `credentials must be scrubbed, got: ${missingId}`); + }); + + it('rejects non-http schemes in URL-keyed fields to close stored-XSS vectors', () => { + const context = { + products: [{ format_ids: [{ agent_url: 'javascript:alert(1)', id: 'x' }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne( + refsResolveCheck({ scope: { key: 'agent_url', equals: '$agent_url' }, on_out_of_scope: 'warn' }), + taskResult, + context + ); + const obs = (result.observations ?? []).find(o => o.kind === 'out_of_scope_ref'); + assert.ok(obs, 'expected out_of_scope_ref observation'); + assert.ok( + !String(obs.ref.agent_url).includes('alert'), + `javascript: payload must be neutered, got: ${obs.ref.agent_url}` + ); + assert.ok(String(obs.ref.agent_url).startsWith(' { + const context = { products: [{ format_ids: [{}] }] }; + const taskResult = { success: true, data: { formats: [{ format_id: {} }] } }; + const [result] = runOne( + refsResolveCheck({ + match_keys: ['constructor', 'toString'], + }), + taskResult, + context + ); + // refsMatch guards via hasOwnProperty, and {} has no OWN `constructor` + // or `toString`, so the match comparator returns false (not a vacuous + // match). The source ref is therefore graded missing but the projected + // report object contains no prototype-chain values. + assert.strictEqual(result.passed, false); + const first = result.actual.missing[0]; + // Own-property only — projectRefForReport filters with hasOwnProperty. + assert.ok(!Object.prototype.hasOwnProperty.call(first, 'constructor')); + assert.ok(!Object.prototype.hasOwnProperty.call(first, 'toString')); + }); + + it('truncates long strings on code-point boundaries, not surrogate halves', () => { + // 300 emoji = 600 UTF-16 code units. Truncation that cleaves surrogate + // pairs produces a lone surrogate that breaks JSON.stringify output. + const longEmoji = '🎯'.repeat(300); + const context = { + products: [{ format_ids: [{ agent_url: AGENT_URL, id: longEmoji }] }], + }; + const taskResult = { success: true, data: { formats: [] } }; + const [result] = runOne(refsResolveCheck(), taskResult, context); + const missingId = result.actual.missing[0].id; + // JSON.stringify must not produce invalid surrogate sequences. + const roundTrip = JSON.parse(JSON.stringify(missingId)); + assert.strictEqual(typeof roundTrip, 'string'); + assert.ok(missingId.endsWith('…'), 'should still end with ellipsis'); + // No lone surrogates: every high surrogate must have a low-surrogate pair. + for (let i = 0; i < missingId.length; i++) { + const code = missingId.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = missingId.charCodeAt(i + 1); + assert.ok(next >= 0xdc00 && next <= 0xdfff, `lone high surrogate at ${i}`); + i++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + assert.fail(`lone low surrogate at ${i}`); + } + } + }); }); From 81ec22de89e8cba8febfd76bced9f7509ebddd27 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 21 Apr 2026 11:11:16 -0400 Subject: [PATCH 2/3] chore: prettier format + regenerate types from upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failures on PR #717: - Code Quality: prettier formatting on the two refs_resolve files edited in this branch. - Test & Build: generated tool types out of sync with upstream `latest` schema bundle, which added five new ListScenarios values (seed_product, seed_pricing_option, seed_creative, seed_plan, seed_media_buy) and refined the scenarios description. Drift is unrelated to this PR — first push to main since the upstream change. Re-ran `prettier --write`, `npm run sync-schemas`, `npm run generate-types`, and `npm run generate-wellknown-schemas`. Tests pass locally (4965/0/6). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/test-controller.ts | 6 ++++-- src/lib/testing/storyboard/validations.ts | 5 ++++- src/lib/types/schemas.generated.ts | 4 ++-- src/lib/types/tools.generated.ts | 7 ++++++- test/lib/storyboard-validations-refs-resolve.test.js | 5 +---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/lib/server/test-controller.ts b/src/lib/server/test-controller.ts index 17b0f862d..3d37c05a7 100644 --- a/src/lib/server/test-controller.ts +++ b/src/lib/server/test-controller.ts @@ -111,8 +111,10 @@ import { toStructuredContent } from './responses'; /** Scenario names advertised via `list_scenarios` (force_* and simulate_*). * Seed scenarios are NOT advertised — the spec treats them as universal - * request-time capabilities, not a discoverable subset. */ -export type ControllerScenario = ListScenariosSuccess['scenarios'][number]; + * request-time capabilities, not a discoverable subset. The upstream + * `ListScenariosSuccess['scenarios']` union includes seeds (open-for- + * extension), so we explicitly subtract them here. */ +export type ControllerScenario = Exclude; /** Scenario names accepted in `scenario` requests but not advertised via * `list_scenarios`. Sellers opt in by implementing the matching store method. */ diff --git a/src/lib/testing/storyboard/validations.ts b/src/lib/testing/storyboard/validations.ts index 925087ff7..8ff440617 100644 --- a/src/lib/testing/storyboard/validations.ts +++ b/src/lib/testing/storyboard/validations.ts @@ -1108,7 +1108,10 @@ function sanitizeFieldString(value: string, isUrlField: boolean): string { // aren't cleaved into invalid UTF-16 strings. let out = value; if (out.length > REF_FIELD_MAX_LEN) { - out = Array.from(out).slice(0, REF_FIELD_MAX_LEN - 1).join('') + '…'; + out = + Array.from(out) + .slice(0, REF_FIELD_MAX_LEN - 1) + .join('') + '…'; } if (isUrlField) { try { diff --git a/src/lib/types/schemas.generated.ts b/src/lib/types/schemas.generated.ts index b0b39adfe..752d46fe9 100644 --- a/src/lib/types/schemas.generated.ts +++ b/src/lib/types/schemas.generated.ts @@ -1,5 +1,5 @@ // Generated Zod v4 schemas from TypeScript types -// Generated at: 2026-04-21T12:04:14.537Z +// Generated at: 2026-04-21T15:10:17.390Z // Sources: // - core.generated.ts (core types) // - tools.generated.ts (tool types) @@ -5460,7 +5460,7 @@ export const ComplyTestControllerRequestSchema = z.object({ export const ListScenariosSuccessSchema = z.object({ success: z.literal(true), - scenarios: z.array(z.union([z.literal("force_creative_status"), z.literal("force_account_status"), z.literal("force_media_buy_status"), z.literal("force_session_status"), z.literal("simulate_delivery"), z.literal("simulate_budget_spend")])), + scenarios: z.array(z.union([z.literal("force_creative_status"), z.literal("force_account_status"), z.literal("force_media_buy_status"), z.literal("force_session_status"), z.literal("simulate_delivery"), z.literal("simulate_budget_spend"), z.literal("seed_product"), z.literal("seed_pricing_option"), z.literal("seed_creative"), z.literal("seed_plan"), z.literal("seed_media_buy")])), context: ContextObjectSchema.optional(), ext: ExtensionObjectSchema.optional() }).passthrough(); diff --git a/src/lib/types/tools.generated.ts b/src/lib/types/tools.generated.ts index 5906485ea..8e862b12e 100644 --- a/src/lib/types/tools.generated.ts +++ b/src/lib/types/tools.generated.ts @@ -13937,7 +13937,7 @@ export type ComplyTestControllerResponse = export interface ListScenariosSuccess { success: true; /** - * Scenarios this seller has implemented + * Scenarios this seller has implemented. Runners and sellers MUST accept unknown scenario strings (open-for-extension) — new scenarios may be added in additive minor bumps. */ scenarios: ( | 'force_creative_status' @@ -13946,6 +13946,11 @@ export interface ListScenariosSuccess { | 'force_session_status' | 'simulate_delivery' | 'simulate_budget_spend' + | 'seed_product' + | 'seed_pricing_option' + | 'seed_creative' + | 'seed_plan' + | 'seed_media_buy' )[]; context?: ContextObject; ext?: ExtensionObject; diff --git a/test/lib/storyboard-validations-refs-resolve.test.js b/test/lib/storyboard-validations-refs-resolve.test.js index e466fea22..d9fcd37ec 100644 --- a/test/lib/storyboard-validations-refs-resolve.test.js +++ b/test/lib/storyboard-validations-refs-resolve.test.js @@ -763,10 +763,7 @@ describe('validateRefsResolve', () => { const firstRef = kinds.indexOf('out_of_scope_ref'); assert.ok(pagIdx !== -1, 'target_paginated must be preserved'); assert.ok(scopeIdx !== -1, 'scope_excluded_all_refs must be preserved'); - assert.ok( - pagIdx < firstRef && scopeIdx < firstRef, - 'meta-observations must precede per-ref observations' - ); + assert.ok(pagIdx < firstRef && scopeIdx < firstRef, 'meta-observations must precede per-ref observations'); }); // ────────────────────────────────────────────────────────── From 9a5b53551eb87d6653fda665f5f68c4c9c498a32 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 21 Apr 2026 11:16:41 -0400 Subject: [PATCH 3/3] docs: regenerate agent docs for v5.8.0 stamp CI ci:docs-check failed because docs/llms.txt and docs/TYPE-SUMMARY.md still carry the v5.7.0 generated stamp; the version.ts drift correction in the prior commit bumped the library version stamp to v5.8.0. Re-ran `npm run generate-agent-docs`. Diff is stamp-only (date + version line); no content drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/TYPE-SUMMARY.md | 4 ++-- docs/llms.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TYPE-SUMMARY.md b/docs/TYPE-SUMMARY.md index 0876fbf10..0cf6c35af 100644 --- a/docs/TYPE-SUMMARY.md +++ b/docs/TYPE-SUMMARY.md @@ -1,7 +1,7 @@ # AdCP Type Summary -> Generated at: 2026-04-20 -> @adcp/client v5.7.0 +> Generated at: 2026-04-21 +> @adcp/client v5.8.0 Curated reference of the types that matter for using the AdCP client. For full generated types see `src/lib/types/tools.generated.ts` and `src/lib/types/core.generated.ts`. diff --git a/docs/llms.txt b/docs/llms.txt index ffba57112..75dd6bc28 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,7 +1,7 @@ # Ad Context Protocol (AdCP) > Generated at: 2026-04-21 -> Library: @adcp/client v5.7.0 +> Library: @adcp/client v5.8.0 > AdCP major version: 3 > Canonical URL: https://adcontextprotocol.github.io/adcp-client/llms.txt