@openwop/openwop-conformance 1.1.1 → 1.3.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.
- package/CHANGELOG.md +90 -0
- package/README.md +2 -2
- package/api/redocly.yaml +15 -0
- package/coverage.md +27 -14
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +18 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +264 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +35 -1
- package/schemas/run-event.schema.json +2 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/driver.ts +15 -0
- package/src/lib/env.ts +51 -0
- package/src/lib/event-log-query.ts +62 -0
- package/src/lib/fixtures.ts +38 -1
- package/src/lib/host-toggle.ts +54 -0
- package/src/lib/multi-agent-capabilities.ts +10 -0
- package/src/lib/otel-scrape.ts +59 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +99 -0
- package/src/scenarios/blob-roundtrip.test.ts +0 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
- package/src/scenarios/fixtures-gating.test.ts +139 -1
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
- package/src/scenarios/pack-registry-publish.test.ts +231 -51
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/provider-usage.test.ts +185 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
- package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
- package/src/scenarios/spec-corpus-validity.test.ts +17 -1
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +85 -0
- package/src/scenarios/table-schema-enforcement.test.ts +84 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-cursor-pagination — RFC 0016 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement-shape). RFC 0016 promoted to `Active`
|
|
5
|
+
* 2026-05-17. The matching `capabilities.tableStorage` block has landed in
|
|
6
|
+
* `schemas/capabilities.schema.json`. This scenario asserts the advertisement
|
|
7
|
+
* shape against any host that boots the conformance suite, and keeps the
|
|
8
|
+
* deeper behavioral assertions as `it.todo()` until a reference host wires
|
|
9
|
+
* a test seam.
|
|
10
|
+
*
|
|
11
|
+
* Summary: query MUST support filter + cursor pagination.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0016-*.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { driver } from '../lib/driver.js';
|
|
18
|
+
|
|
19
|
+
interface DiscoveryDoc {
|
|
20
|
+
capabilities?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
const res = await driver.get('/.well-known/openwop');
|
|
25
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
26
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
27
|
+
const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["tableStorage"] : undefined;
|
|
28
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('table-cursor-pagination: advertisement shape (RFC 0016)', () => {
|
|
32
|
+
it('capabilities.tableStorage is either absent or a well-formed object', async () => {
|
|
33
|
+
const cap = await readCap();
|
|
34
|
+
if (cap === null) return; // host doesn't advertise — skip
|
|
35
|
+
expect(
|
|
36
|
+
typeof cap.supported,
|
|
37
|
+
driver.describe(
|
|
38
|
+
'capabilities.schema.json §tableStorage',
|
|
39
|
+
'capabilities.tableStorage.supported MUST be a boolean when present',
|
|
40
|
+
),
|
|
41
|
+
).toBe('boolean');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
46
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'table', op, args });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('table-cursor-pagination: behavioral (RFC 0016 §B point 3)', () => {
|
|
50
|
+
it('first page returns N rows + nextCursor; second page resumes; final page returns nextCursor:null', async () => {
|
|
51
|
+
const probe = await call('query', { table: '__probe__', limit: 1 });
|
|
52
|
+
if (probe.status === 404) return; // seam not exposed
|
|
53
|
+
const table = `pag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
// Seed 5 rows with deterministic ids so cursor ordering is testable.
|
|
55
|
+
for (let i = 1; i <= 5; i++) {
|
|
56
|
+
await call('insert', { table, row: { id: `row-${i.toString().padStart(2, '0')}`, n: i } });
|
|
57
|
+
}
|
|
58
|
+
// Page 1: limit=2
|
|
59
|
+
const p1 = await call('query', { table, limit: 2 });
|
|
60
|
+
const b1 = p1.json as { rows?: Array<{ id: string }>; nextCursor?: string | null };
|
|
61
|
+
expect(Array.isArray(b1.rows) && b1.rows.length === 2).toBe(true);
|
|
62
|
+
expect(
|
|
63
|
+
typeof b1.nextCursor === 'string' && b1.nextCursor.length > 0,
|
|
64
|
+
driver.describe('RFC 0016 §B point 3', 'first page MUST surface nextCursor when more results remain'),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
|
|
67
|
+
// Page 2: cursor from page 1, limit=2
|
|
68
|
+
const p2 = await call('query', { table, limit: 2, cursor: b1.nextCursor });
|
|
69
|
+
const b2 = p2.json as { rows?: Array<{ id: string }>; nextCursor?: string | null };
|
|
70
|
+
expect(b2.rows?.length).toBe(2);
|
|
71
|
+
expect(
|
|
72
|
+
b2.rows![0]!.id > b1.rows![1]!.id,
|
|
73
|
+
driver.describe('RFC 0016 §B point 3', 'second page MUST resume AFTER the last id of the previous page'),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Page 3: final page — only 1 row left, nextCursor MUST be null
|
|
77
|
+
const p3 = await call('query', { table, limit: 2, cursor: b2.nextCursor });
|
|
78
|
+
const b3 = p3.json as { rows?: Array<{ id: string }>; nextCursor?: string | null };
|
|
79
|
+
expect(b3.rows?.length).toBe(1);
|
|
80
|
+
expect(
|
|
81
|
+
b3.nextCursor,
|
|
82
|
+
driver.describe('RFC 0016 §B point 3', 'final page (no more results) MUST surface nextCursor: null'),
|
|
83
|
+
).toBe(null);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-schema-enforcement — RFC 0016 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement-shape). RFC 0016 promoted to `Active`
|
|
5
|
+
* 2026-05-17. The matching `capabilities.tableStorage` block has landed in
|
|
6
|
+
* `schemas/capabilities.schema.json`. This scenario asserts the advertisement
|
|
7
|
+
* shape against any host that boots the conformance suite, and keeps the
|
|
8
|
+
* deeper behavioral assertions as `it.todo()` until a reference host wires
|
|
9
|
+
* a test seam.
|
|
10
|
+
*
|
|
11
|
+
* Summary: Subsequent rows MUST conform to the schema established on first insert.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0016-*.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { driver } from '../lib/driver.js';
|
|
18
|
+
|
|
19
|
+
interface DiscoveryDoc {
|
|
20
|
+
capabilities?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
const res = await driver.get('/.well-known/openwop');
|
|
25
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
26
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
27
|
+
const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["tableStorage"] : undefined;
|
|
28
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('table-schema-enforcement: advertisement shape (RFC 0016)', () => {
|
|
32
|
+
it('capabilities.tableStorage is either absent or a well-formed object', async () => {
|
|
33
|
+
const cap = await readCap();
|
|
34
|
+
if (cap === null) return; // host doesn't advertise — skip
|
|
35
|
+
expect(
|
|
36
|
+
typeof cap.supported,
|
|
37
|
+
driver.describe(
|
|
38
|
+
'capabilities.schema.json §tableStorage',
|
|
39
|
+
'capabilities.tableStorage.supported MUST be a boolean when present',
|
|
40
|
+
),
|
|
41
|
+
).toBe('boolean');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
46
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'table', op, args });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('table-schema-enforcement: behavioral (RFC 0016 §B point 2)', () => {
|
|
50
|
+
it('first insert declares schema; subsequent insert with wrong column type is rejected', async () => {
|
|
51
|
+
const probe = await call('insert', { table: '__probe__', row: { id: 'probe-0' } });
|
|
52
|
+
if (probe.status === 404) return; // seam not exposed
|
|
53
|
+
const table = `sch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
// First insert — declares the schema from this row's columns.
|
|
55
|
+
const first = await call('insert', {
|
|
56
|
+
table,
|
|
57
|
+
row: { id: 'row-1', name: 'alice', count: 42, active: true },
|
|
58
|
+
});
|
|
59
|
+
expect(first.status).toBe(200);
|
|
60
|
+
|
|
61
|
+
// Second insert — matching schema; MUST succeed.
|
|
62
|
+
const second = await call('insert', {
|
|
63
|
+
table,
|
|
64
|
+
row: { id: 'row-2', name: 'bob', count: 7, active: false },
|
|
65
|
+
});
|
|
66
|
+
expect(second.status).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Third insert — `count` declared as number; sending a string MUST be rejected.
|
|
69
|
+
const bad = await call('insert', {
|
|
70
|
+
table,
|
|
71
|
+
row: { id: 'row-3', name: 'mallory', count: 'oops-a-string', active: true },
|
|
72
|
+
});
|
|
73
|
+
expect(
|
|
74
|
+
bad.status >= 400 && bad.status < 500,
|
|
75
|
+
driver.describe('RFC 0016 §B point 2', 'type-divergent insert MUST be rejected with 4xx'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
const body = bad.json as { error?: { code?: string } | string };
|
|
78
|
+
const code = typeof body.error === 'string' ? body.error : body.error?.code;
|
|
79
|
+
expect(
|
|
80
|
+
code,
|
|
81
|
+
driver.describe('RFC 0016 §B point 2', 'rejection MUST carry the table_schema_violation error code'),
|
|
82
|
+
).toBe('table_schema_violation');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-knn-roundtrip — RFC 0018 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement-shape). RFC 0018 promoted to `Active`
|
|
5
|
+
* 2026-05-17. The matching `capabilities.vectorStore` block has landed in
|
|
6
|
+
* `schemas/capabilities.schema.json`. This scenario asserts the advertisement
|
|
7
|
+
* shape against any host that boots the conformance suite, and keeps the
|
|
8
|
+
* deeper behavioral assertions as `it.todo()` until a reference host wires
|
|
9
|
+
* a test seam.
|
|
10
|
+
*
|
|
11
|
+
* Summary: upsert then query returns the same vectors in top-k order.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0018-*.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { driver } from '../lib/driver.js';
|
|
18
|
+
|
|
19
|
+
interface DiscoveryDoc {
|
|
20
|
+
capabilities?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
const res = await driver.get('/.well-known/openwop');
|
|
25
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
26
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
27
|
+
const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["vectorStore"] : undefined;
|
|
28
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('vector-knn-roundtrip: advertisement shape (RFC 0018)', () => {
|
|
32
|
+
it('capabilities.vectorStore is either absent or a well-formed object', async () => {
|
|
33
|
+
const cap = await readCap();
|
|
34
|
+
if (cap === null) return; // host doesn't advertise — skip
|
|
35
|
+
expect(
|
|
36
|
+
typeof cap.supported,
|
|
37
|
+
driver.describe(
|
|
38
|
+
'capabilities.schema.json §vectorStore',
|
|
39
|
+
'capabilities.vectorStore.supported MUST be a boolean when present',
|
|
40
|
+
),
|
|
41
|
+
).toBe('boolean');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
46
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'vector', op, args });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('vector-knn-roundtrip: behavioral (RFC 0018 §A.vectorStore)', () => {
|
|
50
|
+
it('upsert 10 vectors → query with one of them returns it as the top match', async () => {
|
|
51
|
+
const probe = await call('query', { namespace: '__probe__', vector: [1, 0], topK: 1 });
|
|
52
|
+
if (probe.status === 404) return; // seam not exposed
|
|
53
|
+
const namespace = `knn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
const items = Array.from({ length: 10 }, (_, i) => ({
|
|
55
|
+
id: `vec-${i}`,
|
|
56
|
+
vector: [Math.cos((i * Math.PI) / 5), Math.sin((i * Math.PI) / 5)],
|
|
57
|
+
}));
|
|
58
|
+
const upsertRes = await call('upsert', { namespace, items });
|
|
59
|
+
expect(upsertRes.status).toBe(200);
|
|
60
|
+
|
|
61
|
+
const queryRes = await call('query', { namespace, vector: items[3]!.vector, topK: 1 });
|
|
62
|
+
expect(queryRes.status).toBe(200);
|
|
63
|
+
const body = queryRes.json as { matches?: Array<{ id?: string; score?: number }> };
|
|
64
|
+
expect(Array.isArray(body.matches), 'matches MUST be an array').toBe(true);
|
|
65
|
+
expect(body.matches!.length).toBeGreaterThan(0);
|
|
66
|
+
expect(
|
|
67
|
+
body.matches![0]!.id,
|
|
68
|
+
driver.describe('RFC 0018 §A.vectorStore', 'query with an indexed vector MUST return it as the top match'),
|
|
69
|
+
).toBe('vec-3');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('topK respects the configured limit', async () => {
|
|
73
|
+
const probe = await call('query', { namespace: '__probe__', vector: [1, 0], topK: 1 });
|
|
74
|
+
if (probe.status === 404) return;
|
|
75
|
+
const namespace = `topk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
76
|
+
const items = Array.from({ length: 8 }, (_, i) => ({
|
|
77
|
+
id: `t-${i}`,
|
|
78
|
+
vector: [i / 10, 1 - i / 10],
|
|
79
|
+
}));
|
|
80
|
+
await call('upsert', { namespace, items });
|
|
81
|
+
const r3 = await call('query', { namespace, vector: [0.5, 0.5], topK: 3 });
|
|
82
|
+
const body = r3.json as { matches?: unknown[] };
|
|
83
|
+
expect(
|
|
84
|
+
Array.isArray(body.matches) && body.matches.length <= 3,
|
|
85
|
+
driver.describe('RFC 0018 §A.vectorStore', 'query MUST return at most topK matches'),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF-5 close-out — receiver-side rejection contract per
|
|
3
|
+
* `spec/v1/webhooks.md` §"Signature recipe" + §"Replay-attack
|
|
4
|
+
* resistance". The companion to `webhook-signed-delivery.test.ts`
|
|
5
|
+
* (which verifies the FORWARD direction: host signs correctly) —
|
|
6
|
+
* this scenario verifies the REVERSE direction: a properly-
|
|
7
|
+
* implemented receiver MUST reject five named adversarial inputs.
|
|
8
|
+
*
|
|
9
|
+
* The reference receiver implementation lives at
|
|
10
|
+
* `conformance/src/lib/webhook-receiver.ts`. The SDK ships an
|
|
11
|
+
* identical-behavior `verifyWebhookSignature` helper across all
|
|
12
|
+
* three reference SDKs (SDK-3 close-out).
|
|
13
|
+
*
|
|
14
|
+
* Five adversarial cases:
|
|
15
|
+
*
|
|
16
|
+
* 1. Tampered body — host's HMAC is valid for body B; adversary
|
|
17
|
+
* delivers body B'. Receiver MUST reject with
|
|
18
|
+
* `signature_mismatch`.
|
|
19
|
+
* 2. Tampered HMAC — body is valid; adversary flips a byte of the
|
|
20
|
+
* v1=<hex> signature. Receiver MUST reject with
|
|
21
|
+
* `signature_mismatch`.
|
|
22
|
+
* 3. Stale timestamp — body + HMAC are valid but timestamp is
|
|
23
|
+
* older than the default 5-minute window. Receiver MUST reject
|
|
24
|
+
* with `timestamp_expired`.
|
|
25
|
+
* 4. Replayed signature — adversary resends a previously-accepted
|
|
26
|
+
* delivery within the window. Receiver MUST reject with
|
|
27
|
+
* `duplicate_signature`.
|
|
28
|
+
* 5. Wrong algorithm — host sends `algorithm: v2` (a future
|
|
29
|
+
* version a v1-only receiver doesn't recognize). Receiver MUST
|
|
30
|
+
* reject with `wrong_algorithm`.
|
|
31
|
+
*
|
|
32
|
+
* This scenario is purely receiver-side; it does NOT touch the host
|
|
33
|
+
* under test. It runs unconditionally (no capability gating).
|
|
34
|
+
*
|
|
35
|
+
* @see spec/v1/webhooks.md
|
|
36
|
+
* @see conformance/src/lib/webhook-receiver.ts
|
|
37
|
+
* @see sdk/typescript/src/webhook-helpers.ts
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it, expect } from 'vitest';
|
|
41
|
+
import {
|
|
42
|
+
createReceiverState,
|
|
43
|
+
signPayload,
|
|
44
|
+
verifyWebhookDelivery,
|
|
45
|
+
} from '../lib/webhook-receiver.js';
|
|
46
|
+
|
|
47
|
+
describe('webhook-receiver-adversarial: receiver rejects five canonical attacks', () => {
|
|
48
|
+
const secret = 'test-secret-do-not-use-in-prod';
|
|
49
|
+
const body = JSON.stringify({ runId: 'r-conformance', type: 'run.completed' });
|
|
50
|
+
const nowSec = 1_715_775_600;
|
|
51
|
+
const ts = nowSec;
|
|
52
|
+
|
|
53
|
+
it('positive control: receiver accepts a freshly-signed valid delivery', () => {
|
|
54
|
+
const state = createReceiverState();
|
|
55
|
+
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
56
|
+
const result = verifyWebhookDelivery(
|
|
57
|
+
secret,
|
|
58
|
+
signatureHeader,
|
|
59
|
+
algorithmHeader,
|
|
60
|
+
timestampHeader,
|
|
61
|
+
body,
|
|
62
|
+
state,
|
|
63
|
+
{ nowSeconds: nowSec },
|
|
64
|
+
);
|
|
65
|
+
expect(
|
|
66
|
+
result.accepted,
|
|
67
|
+
'webhooks.md §"Signature recipe": valid signature + fresh timestamp + correct algorithm MUST be accepted',
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('case 1: tampered body → signature_mismatch', () => {
|
|
72
|
+
const state = createReceiverState();
|
|
73
|
+
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
74
|
+
const tamperedBody = JSON.stringify({ runId: 'r-conformance', type: 'run.failed' });
|
|
75
|
+
const result = verifyWebhookDelivery(
|
|
76
|
+
secret,
|
|
77
|
+
signatureHeader,
|
|
78
|
+
algorithmHeader,
|
|
79
|
+
timestampHeader,
|
|
80
|
+
tamperedBody,
|
|
81
|
+
state,
|
|
82
|
+
{ nowSeconds: nowSec },
|
|
83
|
+
);
|
|
84
|
+
expect(result.accepted).toBe(false);
|
|
85
|
+
if (!result.accepted) {
|
|
86
|
+
expect(
|
|
87
|
+
result.reason,
|
|
88
|
+
'webhooks.md §"Signature recipe": tampered body MUST be rejected with signature_mismatch',
|
|
89
|
+
).toBe('signature_mismatch');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('case 2: tampered HMAC → signature_mismatch', () => {
|
|
94
|
+
const state = createReceiverState();
|
|
95
|
+
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
96
|
+
// Flip one hex character of the v1=<hex> signature.
|
|
97
|
+
const flipIndex = 5;
|
|
98
|
+
const orig = signatureHeader[flipIndex]!;
|
|
99
|
+
const replacement = orig === '0' ? '1' : '0';
|
|
100
|
+
const tampered = signatureHeader.slice(0, flipIndex) + replacement + signatureHeader.slice(flipIndex + 1);
|
|
101
|
+
const result = verifyWebhookDelivery(
|
|
102
|
+
secret,
|
|
103
|
+
tampered,
|
|
104
|
+
algorithmHeader,
|
|
105
|
+
timestampHeader,
|
|
106
|
+
body,
|
|
107
|
+
state,
|
|
108
|
+
{ nowSeconds: nowSec },
|
|
109
|
+
);
|
|
110
|
+
expect(result.accepted).toBe(false);
|
|
111
|
+
if (!result.accepted) {
|
|
112
|
+
expect(result.reason).toBe('signature_mismatch');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('case 3: stale timestamp → timestamp_expired', () => {
|
|
117
|
+
const state = createReceiverState();
|
|
118
|
+
const staleTs = nowSec - 10_000; // way past the 5-minute window
|
|
119
|
+
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, staleTs, body);
|
|
120
|
+
const result = verifyWebhookDelivery(
|
|
121
|
+
secret,
|
|
122
|
+
signatureHeader,
|
|
123
|
+
algorithmHeader,
|
|
124
|
+
timestampHeader,
|
|
125
|
+
body,
|
|
126
|
+
state,
|
|
127
|
+
{ nowSeconds: nowSec },
|
|
128
|
+
);
|
|
129
|
+
expect(result.accepted).toBe(false);
|
|
130
|
+
if (!result.accepted) {
|
|
131
|
+
expect(
|
|
132
|
+
result.reason,
|
|
133
|
+
'webhooks.md §"Replay-attack resistance": timestamp older than freshness window MUST be rejected with timestamp_expired',
|
|
134
|
+
).toBe('timestamp_expired');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('case 4: replayed signature → duplicate_signature', () => {
|
|
139
|
+
const state = createReceiverState();
|
|
140
|
+
const { signatureHeader, timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
141
|
+
// First delivery accepted.
|
|
142
|
+
const first = verifyWebhookDelivery(
|
|
143
|
+
secret,
|
|
144
|
+
signatureHeader,
|
|
145
|
+
algorithmHeader,
|
|
146
|
+
timestampHeader,
|
|
147
|
+
body,
|
|
148
|
+
state,
|
|
149
|
+
{ nowSeconds: nowSec },
|
|
150
|
+
);
|
|
151
|
+
expect(first.accepted).toBe(true);
|
|
152
|
+
// Replay — same signature, same body, same timestamp, still
|
|
153
|
+
// within the window.
|
|
154
|
+
const replay = verifyWebhookDelivery(
|
|
155
|
+
secret,
|
|
156
|
+
signatureHeader,
|
|
157
|
+
algorithmHeader,
|
|
158
|
+
timestampHeader,
|
|
159
|
+
body,
|
|
160
|
+
state,
|
|
161
|
+
{ nowSeconds: nowSec },
|
|
162
|
+
);
|
|
163
|
+
expect(replay.accepted).toBe(false);
|
|
164
|
+
if (!replay.accepted) {
|
|
165
|
+
expect(
|
|
166
|
+
replay.reason,
|
|
167
|
+
'webhooks.md §"Replay-attack resistance": replay of an already-accepted signature MUST be rejected with duplicate_signature',
|
|
168
|
+
).toBe('duplicate_signature');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('case 5: wrong algorithm → wrong_algorithm', () => {
|
|
173
|
+
const state = createReceiverState();
|
|
174
|
+
const { signatureHeader, timestampHeader } = signPayload(secret, ts, body);
|
|
175
|
+
const result = verifyWebhookDelivery(
|
|
176
|
+
secret,
|
|
177
|
+
signatureHeader,
|
|
178
|
+
'v2',
|
|
179
|
+
timestampHeader,
|
|
180
|
+
body,
|
|
181
|
+
state,
|
|
182
|
+
{ nowSeconds: nowSec },
|
|
183
|
+
);
|
|
184
|
+
expect(result.accepted).toBe(false);
|
|
185
|
+
if (!result.accepted) {
|
|
186
|
+
expect(
|
|
187
|
+
result.reason,
|
|
188
|
+
'webhooks.md §"Signature algorithm versioning": algorithm other than v1 MUST be rejected with wrong_algorithm by a v1-only receiver',
|
|
189
|
+
).toBe('wrong_algorithm');
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('case 6: malformed signature header → malformed_signature_header', () => {
|
|
194
|
+
const state = createReceiverState();
|
|
195
|
+
const { timestampHeader, algorithmHeader } = signPayload(secret, ts, body);
|
|
196
|
+
const result = verifyWebhookDelivery(
|
|
197
|
+
secret,
|
|
198
|
+
'not-a-canonical-header',
|
|
199
|
+
algorithmHeader,
|
|
200
|
+
timestampHeader,
|
|
201
|
+
body,
|
|
202
|
+
state,
|
|
203
|
+
{ nowSeconds: nowSec },
|
|
204
|
+
);
|
|
205
|
+
expect(result.accepted).toBe(false);
|
|
206
|
+
if (!result.accepted) {
|
|
207
|
+
expect(result.reason).toBe('malformed_signature_header');
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|