@openwop/openwop-conformance 2.32.0 → 2.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +4 -2
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/v2-profiles.js +66 -0
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +58 -14
  10. package/scenario-majors.json +5 -2
  11. package/schemas/CORPUS-STAMP.json +8 -8
  12. package/src/cli.ts +4 -2
  13. package/src/lib/a2a-fake-peer.ts +16 -3
  14. package/src/lib/certification-bundle-v3.ts +11 -2
  15. package/src/lib/durability-watch.ts +53 -0
  16. package/src/lib/mcp-fake-server.ts +13 -0
  17. package/src/lib/v2-profiles.ts +66 -0
  18. package/src/lib/webhook-receiver.ts +24 -5
  19. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  20. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  21. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  22. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  23. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  24. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  25. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  27. package/src/scenarios/v2-durability-recovery.test.ts +13 -21
  28. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  29. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  30. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  31. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  32. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  33. package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
  34. package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
@@ -115,9 +115,32 @@ async function register(url: string, major: 1 | 2): Promise<string | null> {
115
115
  }
116
116
  expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
117
117
  const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
118
+ if (typeof id === 'string') registered.push({ id, major });
118
119
  return typeof id === 'string' ? id : null;
119
120
  }
120
121
 
122
+ /**
123
+ * Every subscription this file registers, unregistered after the leg that made
124
+ * it. Until 2.33.1 NOTHING here was ever unregistered. On loopback that was
125
+ * invisible: each leg's receiver bound its own ephemeral port, so a leftover
126
+ * subscription delivered to a dead address. Behind a public front every leg
127
+ * shares ONE URL on ONE pinned port - so leg 1's still-live MAJOR-2
128
+ * subscription delivered its v2 rendering into leg 2, and the major-1 leg read
129
+ * it and failed a host that had rendered both contracts correctly ("a major-1
130
+ * run.started payload MUST validate against the V1 definition … engineVersion
131
+ * must be string"). Found on the v2 reference host's first relaxation-free cut;
132
+ * reproduced with no ingress at all by pinning OPENWOP_WEBHOOK_RECEIVER_PORT on
133
+ * a loopback run. It also left a live subscription on every host this file ever
134
+ * ran against.
135
+ */
136
+ const registered: Array<{ id: string; major: 1 | 2 }> = [];
137
+ async function unregisterAll(): Promise<void> {
138
+ for (const r of registered.splice(0)) {
139
+ const path = `${r.major === 2 ? '' : '/v1'}/webhooks/${encodeURIComponent(r.id)}`;
140
+ try { await driver.delete(path, { headers: { 'OpenWOP-Version': r.major === 2 ? '2.0' : '1.0' } }); } catch { /* best effort: the leg's verdict is already recorded */ }
141
+ }
142
+ }
143
+
121
144
  async function driveRun(): Promise<string> {
122
145
  const create = await driver.post('/runs', { workflowId: FIXTURE });
123
146
  expect(create.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
@@ -135,10 +158,21 @@ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | unde
135
158
  * receive the bare opaque id (versioning.md §5), so the match is on the segment
136
159
  * both spellings share.
137
160
  */
138
- function deliveryFor(deliveries: Delivery[], runId: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
161
+ /** The subscription a delivery says it belongs to, from either header family; undefined when it carries neither. */
162
+ function subscriptionOf(d: Delivery): string | undefined {
163
+ const h = d.headers['openwop-webhook-id'] ?? d.headers['x-openwop-webhook-id'];
164
+ return typeof h === 'string' ? h : Array.isArray(h) ? h[0] : undefined;
165
+ }
166
+ const bare = (id: string): string => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id);
167
+
168
+ function deliveryFor(deliveries: Delivery[], runId: string, webhookId?: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
139
169
  const opaque = runId.includes('/') ? runId.slice(runId.indexOf('/') + 1) : runId;
140
170
  for (const d of deliveries) {
141
171
  if (!d.body.includes(opaque)) continue;
172
+ // Belt and braces beside unregisterAll(): a delivery that NAMES another
173
+ // subscription is not this leg's, whatever URL it arrived on.
174
+ const sub = subscriptionOf(d);
175
+ if (webhookId !== undefined && sub !== undefined && bare(sub) !== bare(webhookId)) continue;
142
176
  let parsed: unknown;
143
177
  try { parsed = JSON.parse(d.body); } catch { continue; }
144
178
  if (parsed === null || typeof parsed !== 'object') continue;
@@ -181,15 +215,16 @@ const V1 = 'https://openwop.dev/spec/v1/';
181
215
 
182
216
  describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
183
217
  let active: Server | null = null;
184
- afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
218
+ afterEach(async () => { await unregisterAll(); const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
185
219
 
186
220
  it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
187
221
  if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
188
222
  if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
189
223
  const receiver = await startReceiver(); active = receiver.server;
190
- if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
224
+ const webhookId = await register(receiver.url, 2);
225
+ if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
191
226
  const runId = await driveRun();
192
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
227
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
193
228
  if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
194
229
  const v2 = validators(2);
195
230
  const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
@@ -211,9 +246,10 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
211
246
  const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
212
247
  if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
213
248
  const receiver = await startReceiver(); active = receiver.server;
214
- if ((await register(receiver.url, 1)) === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
249
+ const webhookId = await register(receiver.url, 1);
250
+ if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
215
251
  const runId = await driveRun();
216
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
252
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
217
253
  if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
218
254
  // The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
219
255
  // §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
@@ -234,11 +270,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
234
270
  const gate = era2Gate(disc);
235
271
  if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
236
272
  const receiver = await startReceiver(); active = receiver.server;
237
- if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
273
+ const webhookId = await register(receiver.url, 2);
274
+ if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
238
275
  const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
239
276
  if (!log.ok) return softSkip(log.kind, log.reason);
240
277
  const runId = log.runId;
241
- const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
278
+ const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
242
279
  // The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
243
280
  // reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
244
281
  // era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * v2-webhook-egress-refusal — the webhook egress guard REFUSES, at major 2.
3
+ *
4
+ * `spec/v2/core/webhooks.md` §SSRF: "At registration a host MUST reject (`400
5
+ * webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and
6
+ * link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`."
7
+ *
8
+ * ── Why this file exists ─────────────────────────────────────────────────────
9
+ * Until 2.33.0 NO major-2 row asserted that sentence. The scenarios that do
10
+ * (`http-client-ssrf`, `webhook-negative`) are registered at major 1 only, and
11
+ * the three major-2 files that name `webhook_url_rejected` name it only as the
12
+ * soft-skip taken when a host refuses the suite's own receiver. So a v2 bundle
13
+ * could certify `openwop-core-standard` — the profile built on `webhooks` — on a
14
+ * host whose guard refused nothing at all.
15
+ *
16
+ * It was found the expensive way: the steward's own reference host had been
17
+ * cutting its certified bundles with the guard open (`OPENWOP_WEBHOOK_ALLOW_PRIVATE`,
18
+ * because every suite fixture was a loopback listener) and recording no
19
+ * relaxation, which `security-defaults.md` §Relaxations forbids. Nothing in the
20
+ * suite could see it. `conformance/README.md` even said so: "the suite cannot
21
+ * detect an undeclared one." For THIS relaxation that was never true — a host
22
+ * that accepts `https://127.0.0.1/…` has shown on the wire that its guard is open.
23
+ *
24
+ * ── The three outcomes ───────────────────────────────────────────────────────
25
+ * every probe refused → executed-pass. The guard holds.
26
+ * a probe ACCEPTED, relaxation
27
+ * DECLARED (OPENWOP_HOST_RELAXATIONS
28
+ * names a `webhooks.*` obligation) → inapplicable, with the declaration
29
+ * quoted. The bundle carries
30
+ * `host.relaxations[]` and the owning
31
+ * profile is denied by that (RFC 0173 §A.2).
32
+ * a probe ACCEPTED, nothing declared → executed-FAIL — and the emitter and
33
+ * verifier deny every profile that owns
34
+ * `webhooks` (`lib/v2-profiles.ts`
35
+ * `profilesDeniedByObservedRelaxation`).
36
+ *
37
+ * Registration-time only, by design: every probe is a destination the suite
38
+ * does not own, so nothing is ever delivered and an accepted registration is
39
+ * deleted at once. Delivery-time re-resolution is a separate MUST that needs a
40
+ * resolver the suite controls; it is not claimed here.
41
+ *
42
+ * @see spec/v2/core/webhooks.md §SSRF
43
+ * @see spec/v2/core/security-defaults.md §Relaxations
44
+ */
45
+
46
+ import { describe, expect, it } from 'vitest';
47
+ import { driver } from '../lib/driver.js';
48
+ import { gateFamily, v2Discovery } from '../lib/v2.js';
49
+ import { readErrorCode } from '../lib/error-envelope.js';
50
+ import { softSkip } from '../lib/soft-skip.js';
51
+ import { req } from '../lib/requirement-ids.js';
52
+
53
+ const ID = 'openwop.requirement.0171.webhook-egress-refused';
54
+ const DOC = 'spec/v2/core/webhooks.md §SSRF';
55
+ const EVENTS = ['run.completed'];
56
+
57
+ /** One destination per class the sentence names. None is ever contacted. */
58
+ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
59
+ { url: 'http://webhook-egress-probe.example.com/hook', cls: 'non-https scheme' },
60
+ { url: 'https://127.0.0.1/openwop-egress-probe', cls: 'loopback (IPv4)' },
61
+ { url: 'https://[::1]/openwop-egress-probe', cls: 'loopback (IPv6)' },
62
+ { url: 'https://localhost/openwop-egress-probe', cls: 'localhost' },
63
+ { url: 'https://10.255.255.1/openwop-egress-probe', cls: 'RFC 1918 (10/8)' },
64
+ { url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
65
+ { url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
66
+ { url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
67
+ ];
68
+
69
+ /** The `webhooks.*` obligations the operator declared relaxed, if any. */
70
+ function declaredWebhookRelaxations(): string[] {
71
+ const raw = process.env['OPENWOP_HOST_RELAXATIONS'];
72
+ if (!raw) return [];
73
+ try {
74
+ const parsed = JSON.parse(raw) as Array<{ obligation?: unknown }>;
75
+ return (Array.isArray(parsed) ? parsed : []).map((r) => String(r.obligation ?? '')).filter((o) => o.split('.')[0] === 'webhooks');
76
+ } catch {
77
+ return []; // the emitter rejects unparseable JSON loudly; this file does not second-guess it
78
+ }
79
+ }
80
+
81
+ describe('v2-webhook-egress-refusal (webhooks.md §SSRF — the guard refuses, at major 2)', () => {
82
+ it('a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected', async () => {
83
+ const doc = await v2Discovery();
84
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
85
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
86
+
87
+ const accepted: string[] = [];
88
+ const wrongCode: string[] = [];
89
+ for (const probe of PROBES) {
90
+ const reg = await driver.post('/webhooks', { url: probe.url, events: EVENTS });
91
+ if (reg.status === 201) {
92
+ accepted.push(`${probe.cls} (${probe.url})`);
93
+ const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
94
+ if (typeof webhookId === 'string') await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
95
+ } else if (!(reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected')) {
96
+ wrongCode.push(`${probe.cls}: ${reg.status} ${String(readErrorCode(reg.json))}`);
97
+ }
98
+ }
99
+
100
+ if (accepted.length > 0) {
101
+ const declared = declaredWebhookRelaxations();
102
+ if (declared.length > 0) {
103
+ return softSkip('inapplicable', `the egress guard is relaxed and the operator DECLARED it (OPENWOP_HOST_RELAXATIONS: ${declared.join(', ')}) — the host accepted ${accepted.length} of ${PROBES.length} destinations the guard MUST refuse (${accepted.join('; ')}). The refusal cannot be witnessed on a host running relaxed; the bundle carries host.relaxations[] and the profile that owns \`webhooks\` does not certify (security-defaults.md §Relaxations)`);
104
+ }
105
+ }
106
+ expect(
107
+ accepted,
108
+ req(ID, DOC, `a host MUST reject (400 webhook_url_rejected) non-https URLs, RFC 1918, loopback, link-local, IPv6 ULA, cloud-metadata hosts and localhost at registration — this host ACCEPTED ${accepted.length} of ${PROBES.length} (${accepted.join('; ')}) and no \`webhooks.*\` relaxation is declared in OPENWOP_HOST_RELAXATIONS. An open guard nobody declared is an UNDECLARED RELAXATION: security-defaults.md §Relaxations requires it in host.relaxations[], and no profile built on \`webhooks\` certifies either way`),
109
+ ).toEqual([]);
110
+ expect(
111
+ wrongCode,
112
+ req(ID, DOC, `the refusal MUST be 400 webhook_url_rejected (spec/v2/errors.json) — other answers: ${wrongCode.join('; ')}`),
113
+ ).toEqual([]);
114
+ }, 60_000);
115
+ });