@openwop/openwop-conformance 1.136.11 → 1.139.0

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 (36) hide show
  1. package/README.md +268 -3
  2. package/coverage.md +2 -2
  3. package/dist/cli.js +8 -1
  4. package/dist/lib/requirement-ledger.js +8 -0
  5. package/dist/lib/scenario-disposition.js +30 -0
  6. package/package.json +1 -1
  7. package/schemas/CORPUS-STAMP.json +2 -2
  8. package/schemas/capabilities.schema.json +1 -1
  9. package/schemas/certification-bundle-v2.schema.json +11 -1
  10. package/src/cli.ts +8 -1
  11. package/src/global-setup.ts +169 -0
  12. package/src/lib/fork-availability.test.ts +69 -0
  13. package/src/lib/fork-availability.ts +61 -0
  14. package/src/lib/global-setup.test.ts +76 -0
  15. package/src/lib/polling.test.ts +80 -0
  16. package/src/lib/polling.ts +39 -2
  17. package/src/lib/requirement-ledger.test.ts +75 -0
  18. package/src/lib/requirement-ledger.ts +9 -0
  19. package/src/lib/risk-disposition.test.ts +91 -0
  20. package/src/lib/scenario-disposition.ts +30 -0
  21. package/src/lib/webhook-receiver.test.ts +76 -0
  22. package/src/lib/webhook-receiver.ts +24 -3
  23. package/src/scenarios/a2a-task-roundtrip.test.ts +66 -10
  24. package/src/scenarios/agent-channel-dispatch.test.ts +3 -3
  25. package/src/scenarios/conformance-execution-witness.test.ts +29 -0
  26. package/src/scenarios/cross-host-traceparent-propagation.test.ts +10 -6
  27. package/src/scenarios/durability-poison-exhaustion.test.ts +154 -0
  28. package/src/scenarios/replay-fanout-suppression.test.ts +328 -0
  29. package/src/scenarios/replay-fork-arbitrary.test.ts +9 -3
  30. package/src/scenarios/replay-fork.test.ts +10 -2
  31. package/src/scenarios/replay-side-effect-suppression.test.ts +3 -2
  32. package/src/scenarios/replayDeterminism.test.ts +8 -6
  33. package/src/scenarios/webhook-receiver-adversarial.test.ts +9 -3
  34. package/src/scenarios/webhook-signed-delivery.test.ts +76 -13
  35. package/src/setup.ts +24 -5
  36. package/vitest.config.ts +7 -0
@@ -18,6 +18,8 @@
18
18
 
19
19
  import { describe, it, expect } from 'vitest';
20
20
  import { driver } from '../lib/driver.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { forkDeclined } from '../lib/fork-availability.js';
21
23
  import { pollUntilTerminal } from '../lib/polling.js';
22
24
  import { isFixtureAdvertised } from '../lib/fixtures.js';
23
25
 
@@ -69,7 +71,7 @@ function structuralShape(events: readonly RawEvent[]): Array<{ type: unknown; no
69
71
  describe('replay-determinism: openwop-replay-fork profile gate', () => {
70
72
  it('host advertising replay.supported MUST also advertise replay.modes', async () => {
71
73
  const replay = await fetchReplayCapability();
72
- if (replay === null || replay.supported !== true) return; // skip-equivalent
74
+ if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
73
75
 
74
76
  expect(Array.isArray(replay.modes), driver.describe(
75
77
  'spec/v1/replay.md',
@@ -91,8 +93,8 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
91
93
  'two replay forks of the same point produce structurally-identical event lists',
92
94
  async () => {
93
95
  const replay = await fetchReplayCapability();
94
- if (replay === null || replay.supported !== true) return; // host doesn't claim replay
95
- if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return; // mode not supported
96
+ if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
97
+ if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return softSkip('inapplicable', 'host advertises replay but not the `replay` mode — this leg is out of scope for it');
96
98
 
97
99
  // Phase 1: complete an original run.
98
100
  const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
@@ -105,7 +107,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
105
107
  mode: 'replay',
106
108
  fromSeq: 0,
107
109
  });
108
- if (fork1.status === 501) return; // mode not implemented; skip-equivalent
110
+ if (forkDeclined(fork1.status, 'determinism fork 1')) return;
109
111
  expect(fork1.status, driver.describe(
110
112
  'spec/v1/replay.md',
111
113
  'POST /v1/runs/{runId}:fork with mode=replay MUST return 201',
@@ -118,7 +120,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
118
120
  mode: 'replay',
119
121
  fromSeq: 0,
120
122
  });
121
- if (fork2.status === 501) return;
123
+ if (forkDeclined(fork2.status, 'determinism fork 2')) return;
122
124
  expect(fork2.status).toBe(201);
123
125
  const fork2Id = (fork2.json as { runId: string }).runId;
124
126
  await pollUntilTerminal(fork2Id, { timeoutMs: 10_000 });
@@ -152,7 +154,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
152
154
  describe.skipIf(SKIP_NO_NOOP)('replay-determinism: branch-mode is permitted to diverge', () => {
153
155
  it('branch mode does NOT need to produce identical event sequences (negative-control)', async () => {
154
156
  const replay = await fetchReplayCapability();
155
- if (replay === null || replay.supported !== true) return;
157
+ if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
156
158
  if (!Array.isArray(replay.modes) || !replay.modes.includes('branch')) return;
157
159
 
158
160
  // Self-test on the spec interpretation: branch and replay are
@@ -17,7 +17,7 @@
17
17
  * delivers body B'. Receiver MUST reject with
18
18
  * `signature_mismatch`.
19
19
  * 2. Tampered HMAC — body is valid; adversary flips a byte of the
20
- * v1=<hex> signature. Receiver MUST reject with
20
+ * sha256=<hex> signature. Receiver MUST reject with
21
21
  * `signature_mismatch`.
22
22
  * 3. Stale timestamp — body + HMAC are valid but timestamp is
23
23
  * older than the default 5-minute window. Receiver MUST reject
@@ -42,6 +42,7 @@ import {
42
42
  createReceiverState,
43
43
  signPayload,
44
44
  verifyWebhookDelivery,
45
+ SIGNATURE_PREFIX,
45
46
  } from '../lib/webhook-receiver.js';
46
47
 
47
48
  describe('webhook-receiver-adversarial: receiver rejects five canonical attacks', () => {
@@ -93,8 +94,13 @@ describe('webhook-receiver-adversarial: receiver rejects five canonical attacks'
93
94
  it('case 2: tampered HMAC → signature_mismatch', () => {
94
95
  const state = createReceiverState();
95
96
  const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
96
- // Flip one hex character of the v1=<hex> signature.
97
- const flipIndex = 5;
97
+ // Flip one hex character of the `sha256=<hex>` signature. Derived from
98
+ // SIGNATURE_PREFIX rather than hard-coded: the literal `5` was inside the
99
+ // hex only while the prefix was the (non-spec) `v1=`, and silently moved
100
+ // INTO the prefix when it was corrected to `sha256=` — turning a
101
+ // tampered-HMAC case into a malformed-header one. The test caught it; the
102
+ // constant is what makes it stay caught.
103
+ const flipIndex = SIGNATURE_PREFIX.length + 2;
98
104
  const orig = signatureHeader[flipIndex]!;
99
105
  const replacement = orig === '0' ? '1' : '0';
100
106
  const tampered = signatureHeader.slice(0, flipIndex) + replacement + signatureHeader.slice(flipIndex + 1);
@@ -7,18 +7,68 @@
7
7
  * 2. `X-openwop-Signature-Algorithm: v1` header is present.
8
8
  * 3. `X-openwop-Signature` is a valid HMAC-SHA256 of
9
9
  * `${timestamp}.${rawBody}` under the subscription secret.
10
- * 4. `X-openwop-Subscription-Id` matches the returned subscription id.
10
+ * 4. `X-openwop-Webhook-Id` matches the returned `webhookId`.
11
+ * (This line said `X-openwop-Subscription-Id` until 2026-08-19, two years
12
+ * after the assertion below stopped checking that header — a docblock
13
+ * asserting more than the code did, in the file that documents a
14
+ * security-relevant contract.)
11
15
  *
12
16
  * Capability-gated: skips when the host does not advertise
13
17
  * `capabilities.webhooks.supported = true`.
14
18
  *
15
- * Operator contract: hosts that implement a SSRF guard on
16
- * `POST /v1/webhooks` (rejecting loopback / RFC1918 / link-local
17
- * destinations to protect deployer infrastructure) MUST allow the test
18
- * receiver. The SQLite reference host bypasses the guard when the
19
- * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` env var is set at boot. Test-only
20
- * hosts SHOULD provide an equivalent opt-in. When the host rejects with
21
- * `400 webhook_url_rejected`, this scenario skips with a warning.
19
+ * Operator contract THREE gates, not one (clarified 2026-08-25).
20
+ *
21
+ * The test receiver is `http://127.0.0.1:{port}/`, and `webhooks.md`
22
+ * forbids it three separate times. A host honoring
23
+ * `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` (or an equivalent opt-in) MUST
24
+ * relax ALL THREE for this scenario to be witnessable:
25
+ *
26
+ * 1. **Scheme.** §"SSRF protection" bullet 1 rejects non-`https://`
27
+ * protocols, and §"Register" says `url` MUST be `https://`. The
28
+ * receiver is plain `http`. This gate is the one the flag's
29
+ * description omitted until 2026-08-25, and it fires FIRST on a
30
+ * host that validates scheme before address.
31
+ * 2. **Registration-time address check.** §"SSRF protection": the
32
+ * server MUST validate subscription URLs at registration time and
33
+ * reject loopback / RFC1918 / link-local / ULA / metadata.
34
+ * 3. **Delivery-time re-resolution.** §"Delivery-time egress
35
+ * validation (RFC 0093)": the dispatcher MUST re-resolve at
36
+ * delivery time and validate every resolved address against the
37
+ * same ranges.
38
+ *
39
+ * Gates 2 and 3 are INDEPENDENT MUSTs at different layers, so "which
40
+ * layer must the opt-in reach" is not a matter of taste: a relaxation
41
+ * reaching only one layer cannot produce a witness. Delivery-only leaves
42
+ * registration returning `400 webhook_url_rejected` (observed on a tier-2
43
+ * host, 2026-08-25); registration-only leaves the dispatcher re-resolving
44
+ * `127.0.0.1` and refusing to connect. A host whose opt-in reaches one
45
+ * layer is not non-conformant — it simply cannot witness this scenario,
46
+ * and that is a property of the test posture, not of its webhook signing.
47
+ *
48
+ * What that costs is more than a checkmark, and it is worth stating plainly
49
+ * because the cost is invisible from inside such a host. This scenario is
50
+ * typically the ONLY automated oracle a host has on its delivery header
51
+ * NAMES: a host-local delivery test almost always compares a hand-written
52
+ * literal in the test to a hand-written literal in the implementation, which
53
+ * is a mirror — typo both and it stays green — while the HMAC assertion
54
+ * beside it recomputes from `node:crypto` and is real. So the names go
55
+ * unchecked exactly where the bytes are checked well. All three reference
56
+ * hosts emitted invented header names for 16 days (openwop-examples#22) and
57
+ * the runs that would have caught it were the ones this scenario could not
58
+ * reach. A host that runs it is not thereby careful; it is thereby measured.
59
+ *
60
+ * Relaxing gates 2 and 3 is test-only posture. See
61
+ * `SECURITY/threat-model-secret-leakage.md` §4.9 for why a
62
+ * registration-time relaxation is the more dangerous of the two: it
63
+ * writes a durable subscription row that survives the flag being turned
64
+ * back off.
65
+ *
66
+ * When the host rejects with `400 webhook_url_rejected`, this scenario
67
+ * records `blocked` (RFC 0148 §A) — NOT a pass. Under a plain
68
+ * `vitest run` the console still prints "1 passed", because that is
69
+ * vitest reporting test outcomes rather than conformance dispositions;
70
+ * the run-end disposition summary (`src/global-setup.ts`) is where the
71
+ * `blocked` becomes visible without `--certify`.
22
72
  *
23
73
  * @see spec/v1/webhooks.md §"Signature scheme"
24
74
  */
@@ -122,10 +172,23 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
122
172
 
123
173
  expect(reg.status, driver.describe(
124
174
  'webhooks.md §"Register"',
125
- 'POST /v1/webhooks MUST return 201 with subscriptionId + secret on success',
175
+ 'POST /v1/webhooks MUST return 201 with webhookId + secret on success',
126
176
  )).toBe(201);
127
- const sub = reg.json as { subscriptionId: string; secret: string };
128
- expect(typeof sub.subscriptionId).toBe('string');
177
+ // `webhookId`, NOT `subscriptionId` (corrected 2026-08-19). `webhooks.md`
178
+ // §"Register" shows `{"webhookId": "wh_a3b9c2", ...}`, `api/openapi.yaml`
179
+ // declares the 201 body `required: [webhookId]` with no `subscriptionId`
180
+ // property at all, and the sibling `webhook-tenant-isolation.test.ts` reads
181
+ // `webhookId`. This file required a field the contract does not define, so a
182
+ // SPEC-CONFORMING host failed at the first assertion — the suite being
183
+ // different from the spec, which is worse than the stricter-than-spec case
184
+ // COMPATIBILITY.md §2.3 forbids. Reported by a tier-2 host emitting exactly
185
+ // what the spec shows. The postgres reference host returns both names, which
186
+ // is why nothing went red here.
187
+ const sub = reg.json as { webhookId: string; secret: string };
188
+ expect(typeof sub.webhookId, driver.describe(
189
+ 'api/openapi.yaml registerWebhook 201',
190
+ 'the 201 body MUST carry `webhookId` (required) — `subscriptionId` is not in the contract',
191
+ )).toBe('string');
129
192
  expect(typeof sub.secret).toBe('string');
130
193
  expect(sub.secret.length).toBeGreaterThan(0);
131
194
 
@@ -174,7 +237,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
174
237
  expect(
175
238
  first.headers['x-openwop-webhook-id'],
176
239
  driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Webhook-Id MUST carry the subscription id'),
177
- ).toBe(sub.subscriptionId);
240
+ ).toBe(sub.webhookId);
178
241
 
179
242
  const timestamp = first.headers['x-openwop-timestamp'];
180
243
  expect(
@@ -201,7 +264,7 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
201
264
  expect(event.runId).toBe(runId);
202
265
 
203
266
  // Cleanup: unregister.
204
- const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.subscriptionId)}`);
267
+ const del = await driver.delete(`/v1/webhooks/${encodeURIComponent(sub.webhookId)}`);
205
268
  expect(del.status).toBeGreaterThanOrEqual(200);
206
269
  expect(del.status).toBeLessThan(300);
207
270
  });
package/src/setup.ts CHANGED
@@ -32,7 +32,7 @@ import { McpFakeServer, setMcpFakeServer } from './lib/mcp-fake-server.js';
32
32
  import { A2AFakePeer, setA2AFakePeer } from './lib/a2a-fake-peer.js';
33
33
  import { afterAll, afterEach, beforeAll, expect } from 'vitest';
34
34
  import { basename } from 'node:path';
35
- import { recordRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
35
+ import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
36
36
  import { requirementIdForFile, resolveFileRecord, type FileTestState } from './lib/scenario-disposition.js';
37
37
  import { softSkipDisposition } from './lib/soft-skip.js';
38
38
  import type { DiscoveryPayload } from './lib/profiles.js';
@@ -279,10 +279,29 @@ afterAll(({}, suite) => {
279
279
  // honest bundle row and the pressure to say why both survive. The rule is
280
280
  // `resolveFileRecord` (pinned by conformance-execution-witness.test.ts).
281
281
  const { disposition, detail } = resolveFileRecord(states, gateReason, assertionCount, softSkipDisposition(file));
282
- try {
283
- recordRequirement(requirementIdForFile(file), disposition, detail, { assertionCount });
284
- } catch {
285
- /* a scenario that recorded its own file id first wins; never fail the file for bookkeeping */
282
+ const fileRequirementId = requirementIdForFile(file);
283
+ // A scenario that classified ITSELF wins outright — including its `detail` and
284
+ // its `assertionCount`.
285
+ //
286
+ // The `catch` below has always made the explicit record win when the two
287
+ // DISAGREE (`recordRequirement` throws on a conflicting disposition). It did
288
+ // not when they AGREE: the automatic call then reached `ledger.set` and
289
+ // silently replaced the scenario's own detail and count with the file-level
290
+ // ones. Invisible until 2026-08-19, when `resolveFileRecord` started attaching
291
+ // a `partial-witness:` marker — a scenario that recorded `executed-pass` for a
292
+ // requirement it really did exercise, in a file whose LAST leg soft-skipped,
293
+ // would have been stamped "may not have witnessed this" over its own explicit
294
+ // finding. That would inject false positives into exactly the measurement the
295
+ // marker exists to produce.
296
+ //
297
+ // So the comment describing this line was true of half the cases. It is true
298
+ // of both now.
299
+ if (!hasRequirement(fileRequirementId)) {
300
+ try {
301
+ recordRequirement(fileRequirementId, disposition, detail, { assertionCount });
302
+ } catch {
303
+ /* never fail a file for bookkeeping */
304
+ }
286
305
  }
287
306
  _fileStates.delete(file);
288
307
  _fileAssertions.delete(file);
package/vitest.config.ts CHANGED
@@ -17,5 +17,12 @@ export default defineConfig({
17
17
  // populates `lib/fixtures.ts` so `describe.skipIf(...)` predicates
18
18
  // see the cached set when scenarios register their tests.
19
19
  setupFiles: ['src/setup.ts'],
20
+ // RFC 0148 §A run-end disposition summary. `setup.ts` records an honest
21
+ // disposition per scenario file on EVERY run; before this, only
22
+ // `--certify` set OPENWOP_LEDGER_PATH, so on a plain `vitest run` the
23
+ // recording was computed and then discarded, leaving "N passed" as the
24
+ // sole artifact for rows the suite had classified `blocked`. This routes
25
+ // the ledger to a temp file and prints what did not witness.
26
+ globalSetup: ['src/global-setup.ts'],
20
27
  },
21
28
  });