@openwop/openwop-conformance 1.1.1 → 1.2.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 +25 -0
- package/README.md +2 -2
- package/coverage.md +26 -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.json +23 -4
- 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-input-mapping-child.json +25 -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-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +12 -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 +248 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +147 -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/workflow-chain-pack-manifest.schema.json +226 -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/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 +173 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -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 +66 -0
- package/src/scenarios/blob-roundtrip.test.ts +48 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
- 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 +47 -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/pause-resume.test.ts +43 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -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 +66 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +47 -0
- package/src/scenarios/table-schema-enforcement.test.ts +47 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +48 -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-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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.correlationReplay — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
|
|
5
|
+
* 2026-05-17 as DRAFT v1.x. Behavioral assertions stay `it.todo()` until a
|
|
6
|
+
* reference host wires the accept path and the cross-process replay seam.
|
|
7
|
+
*
|
|
8
|
+
* Summary: two envelopes in the same run with the same `correlationId` MUST
|
|
9
|
+
* be treated as a re-emission. The second invocation returns the cached
|
|
10
|
+
* `EnvelopeOutcome` synchronously without re-invoking the handler. After
|
|
11
|
+
* process death + recovery, the engine MUST consult the run event log via
|
|
12
|
+
* `causationId = correlationId` and return the cached outcome — the handler
|
|
13
|
+
* runs at most once per `correlationId` per run lifetime. A re-emission with
|
|
14
|
+
* the same `correlationId` but a different `type` MUST be refused with
|
|
15
|
+
* `envelope_correlation_conflict`.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v1/ai-envelope.md §"Replay determinism"
|
|
18
|
+
* @see spec/v1/interrupt.md §"Replay determinism" (parallel contract)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
|
|
24
|
+
interface DiscoveryDoc {
|
|
25
|
+
capabilities?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function isEnvelopeContractsAdvertised(): Promise<boolean> {
|
|
29
|
+
const res = await driver.get('/.well-known/openwop');
|
|
30
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
31
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
32
|
+
const block = top && typeof top === 'object' ? (top['envelopeContracts'] as Record<string, unknown> | undefined) : undefined;
|
|
33
|
+
return Boolean(block && block['advertised'] === true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('aiEnvelope.correlationReplay: advertisement shape (FINAL v1.1)', () => {
|
|
37
|
+
it('host that advertises envelopeContracts.advertised:true claims the replay-determinism contract', async () => {
|
|
38
|
+
if (!(await isEnvelopeContractsAdvertised())) return; // not opted in — skip
|
|
39
|
+
// The contract has no separate capability flag — advertising
|
|
40
|
+
// envelopeContracts is the claim. The behavioral assertions below
|
|
41
|
+
// exercise the contract; this advertisement-shape test exists so
|
|
42
|
+
// a "no envelope contracts at all" host doesn't appear in failure
|
|
43
|
+
// reports for this scenario.
|
|
44
|
+
expect(true).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('aiEnvelope.correlationReplay: engine-state placeholders', () => {
|
|
49
|
+
// The 4 assertions below require the engine to maintain a per-run
|
|
50
|
+
// correlationId → cached-outcome map AND project envelope acceptance
|
|
51
|
+
// onto RunEventDocs with `causationId = envelope.correlationId`.
|
|
52
|
+
//
|
|
53
|
+
// The reference workflow-engine sample's `acceptEnvelope` is a pure
|
|
54
|
+
// function (host/envelopeAcceptor.ts) — it validates + categorizes
|
|
55
|
+
// a single envelope without tracking state across calls. Promoting
|
|
56
|
+
// these to behavioral requires either:
|
|
57
|
+
// (a) extending the acceptor with an injected dedup store
|
|
58
|
+
// (per-run correlationId map keyed by runId), OR
|
|
59
|
+
// (b) a higher-level test seam that wires the acceptor into the
|
|
60
|
+
// run lifecycle + event log.
|
|
61
|
+
//
|
|
62
|
+
// (b) is the spec-faithful path (per ai-envelope.md §"Replay
|
|
63
|
+
// determinism" the dedup is engine-level, not acceptor-level).
|
|
64
|
+
// Tracked as host-impl follow-up.
|
|
65
|
+
it.todo('emit envelope twice with same correlationId → second returns cached outcome; no duplicate RunEventDocs');
|
|
66
|
+
it.todo('emit envelope with correlationId C, then with same C and different type → refuse envelope_correlation_conflict');
|
|
67
|
+
it.todo('cross-process replay: process-death after accept; recovered process re-emits same correlationId → cached outcome, no handler re-invocation');
|
|
68
|
+
it.todo('resulting RunEventDoc.causationId equals the envelope.correlationId (causal chain preserved)');
|
|
69
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.redaction — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
|
|
5
|
+
* 2026-05-17 as DRAFT v1.x. Behavioral assertions stay `it.todo()` until a
|
|
6
|
+
* reference host wires the envelope accept path through the BYOK redaction
|
|
7
|
+
* harness.
|
|
8
|
+
*
|
|
9
|
+
* Summary: AI Envelopes MUST route through the same BYOK redaction harness
|
|
10
|
+
* applied to a fresh `MemoryEntry.put` per `agent-memory.md` §"SR-1
|
|
11
|
+
* secret-redaction invariant". The fact that the LLM was instructed not to
|
|
12
|
+
* emit secrets is NOT evidence to skip redaction — the model can hallucinate
|
|
13
|
+
* secret-shaped substrings from prompt context, in-context examples, or tool
|
|
14
|
+
* results. Redacted material MUST NOT appear in resulting `RunEventDoc`s,
|
|
15
|
+
* OTel span attributes, debug-bundle exports, or error envelopes returned to
|
|
16
|
+
* the client. The pass runs AFTER validation and BEFORE dedup/handler routing
|
|
17
|
+
* in the production-flow ordering.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v1/ai-envelope.md §"Redaction (SR-1 carry-forward)"
|
|
20
|
+
* @see spec/v1/agent-memory.md §"SR-1 secret-redaction invariant"
|
|
21
|
+
* @see SECURITY/invariants.yaml#envelope-redaction-sr-1-carry-forward
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
|
|
27
|
+
interface DiscoveryDoc {
|
|
28
|
+
capabilities?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isBYOKAdvertised(): Promise<boolean> {
|
|
32
|
+
const res = await driver.get('/.well-known/openwop');
|
|
33
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
34
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
35
|
+
const secrets = top && typeof top === 'object' ? top['secrets'] : undefined;
|
|
36
|
+
return Boolean(secrets && typeof secrets === 'object');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('aiEnvelope.redaction: advertisement shape (FINAL v1.1)', () => {
|
|
40
|
+
it('hosts advertising envelopeContracts AND secrets honor SR-1 carry-forward', async () => {
|
|
41
|
+
if (!(await isBYOKAdvertised())) return; // BYOK not advertised — skip
|
|
42
|
+
// The contract is invariant-based, not capability-flag-based. The
|
|
43
|
+
// advertisement-shape check is just "the host claims a BYOK surface";
|
|
44
|
+
// behavioral assertions below exercise the redaction invariant.
|
|
45
|
+
expect(true).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('aiEnvelope.redaction: BYOK-redaction placeholders', () => {
|
|
50
|
+
// The 6 assertions below require the engine's BYOK redaction pipeline
|
|
51
|
+
// (per SECURITY/threat-model-secret-leakage.md SR-1 carry-forward) to
|
|
52
|
+
// hook into envelope acceptance AND every downstream surface that
|
|
53
|
+
// persists envelope content (RunEventDoc, OTel span attributes,
|
|
54
|
+
// debug-bundle export, error envelope projection).
|
|
55
|
+
//
|
|
56
|
+
// The reference workflow-engine sample's `acceptEnvelope` is pure +
|
|
57
|
+
// doesn't touch payload contents. Redaction lives at a different
|
|
58
|
+
// layer (BYOK secretResolver + event-log sanitizer). Promoting these
|
|
59
|
+
// to behavioral requires either:
|
|
60
|
+
// (a) chaining the acceptor through `stripSecretsFromPersisted`
|
|
61
|
+
// before persisting the recorded view, OR
|
|
62
|
+
// (b) an end-to-end test that plants a BYOK canary in an envelope
|
|
63
|
+
// payload, runs through the full accept → emit → persist → export
|
|
64
|
+
// chain, and asserts the canary is absent on every output.
|
|
65
|
+
//
|
|
66
|
+
// (b) is the spec-faithful path. Tracked as host-impl follow-up.
|
|
67
|
+
it.todo('emit envelope whose payload contains a known BYOK substring → substring absent from emitted RunEventDocs');
|
|
68
|
+
it.todo('redacted substring absent from OTel envelope_* span attributes');
|
|
69
|
+
it.todo('redacted substring absent from debug-bundle export');
|
|
70
|
+
it.todo('redacted substring absent from error envelope on validation refusal (no leak via error path)');
|
|
71
|
+
it.todo('redaction marker is the canonical [REDACTED:<reason>] form, NOT a model-generated <REDACTED> string');
|
|
72
|
+
it.todo('redaction runs AFTER schema validation: a payload with redacted-shaped substrings still validates structurally');
|
|
73
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.schemaDrift — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
|
|
5
|
+
* 2026-05-17 as DRAFT v1.x. This scenario asserts the advertisement shape
|
|
6
|
+
* for hosts that opt into envelopeContracts and the optional
|
|
7
|
+
* `envelopeStrictness` knob; behavioral assertions stay `it.todo()` until
|
|
8
|
+
* a reference host wires the accept path.
|
|
9
|
+
*
|
|
10
|
+
* Summary: an LLM emits an envelope whose `schemaVersion` is lower than the
|
|
11
|
+
* host's advertised floor for that kind (`Capabilities.schemaVersions[kind]`).
|
|
12
|
+
* Under `envelopeStrictness: "warn"` (default) the engine MUST attempt
|
|
13
|
+
* validation against the advertised version and log `envelope_schema_version_drift`.
|
|
14
|
+
* Under `envelopeStrictness: "strict"` the engine MUST refuse with
|
|
15
|
+
* `unknown_schema_version`. When the emitted `schemaVersion` is HIGHER than
|
|
16
|
+
* advertised, the engine MUST refuse regardless of strictness.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v1/ai-envelope.md §"Schema discipline"
|
|
19
|
+
* @see spec/v1/ai-envelope.md §"Capability handshake integration"
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
|
|
25
|
+
interface DiscoveryDoc {
|
|
26
|
+
capabilities?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function isEnvelopeContractsAdvertised(): Promise<boolean> {
|
|
30
|
+
const res = await driver.get('/.well-known/openwop');
|
|
31
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
32
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
33
|
+
const block = top && typeof top === 'object' ? (top['envelopeContracts'] as Record<string, unknown> | undefined) : undefined;
|
|
34
|
+
return Boolean(block && block['advertised'] === true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('aiEnvelope.schemaDrift: advertisement shape (FINAL v1.1)', () => {
|
|
38
|
+
it('capabilities.envelopeStrictness is either absent (treated as "warn") or "warn" | "strict"', async () => {
|
|
39
|
+
const res = await driver.get('/.well-known/openwop');
|
|
40
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
41
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
42
|
+
const val = top && typeof top === 'object' ? top['envelopeStrictness'] : undefined;
|
|
43
|
+
if (val === undefined) return; // absent → treated as 'warn'; skip
|
|
44
|
+
expect(
|
|
45
|
+
val === 'warn' || val === 'strict',
|
|
46
|
+
driver.describe(
|
|
47
|
+
'ai-envelope.md §"Capability handshake integration"',
|
|
48
|
+
'envelopeStrictness MUST be the literal string "warn" or "strict" when present',
|
|
49
|
+
),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('schemaVersions is non-empty when envelopeContracts.advertised: true', async () => {
|
|
54
|
+
if (!(await isEnvelopeContractsAdvertised())) return; // not opted in — skip
|
|
55
|
+
const res = await driver.get('/.well-known/openwop');
|
|
56
|
+
const body = res.json as { schemaVersions?: Record<string, number>; capabilities?: { schemaVersions?: Record<string, number> } } | undefined;
|
|
57
|
+
const versions = body?.schemaVersions ?? body?.capabilities?.schemaVersions ?? {};
|
|
58
|
+
expect(
|
|
59
|
+
Object.keys(versions).length > 0,
|
|
60
|
+
driver.describe(
|
|
61
|
+
'ai-envelope.md §"Schema version advertisement"',
|
|
62
|
+
'schemaVersions MUST be non-empty when envelopeContracts.advertised is true',
|
|
63
|
+
),
|
|
64
|
+
).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('aiEnvelope.schemaDrift: engine-strictness placeholders', () => {
|
|
69
|
+
// The 4 assertions below require the engine to read both:
|
|
70
|
+
// (a) `Capabilities.schemaVersions[<kind>]` — the advertised floor
|
|
71
|
+
// version the host implements for the kind, AND
|
|
72
|
+
// (b) `Capabilities.envelopeStrictness` — the run-level knob that
|
|
73
|
+
// decides whether below-floor versions warn or refuse.
|
|
74
|
+
//
|
|
75
|
+
// The reference workflow-engine sample's `acceptEnvelope` validates
|
|
76
|
+
// `schemaVersion` as a top-level structural field but does NOT yet
|
|
77
|
+
// cross-reference it against the host's advertised floor or apply
|
|
78
|
+
// the strictness knob. Promoting these to behavioral requires
|
|
79
|
+
// threading both pieces of state through `AcceptOptions` (or making
|
|
80
|
+
// the acceptor close over a discovery snapshot). Tracked as host-
|
|
81
|
+
// impl follow-up; the OTel span attribute (`envelope_schema_version_drift`)
|
|
82
|
+
// is engine-projection scope.
|
|
83
|
+
it.todo('emit envelope with schemaVersion below advertised floor under strictness:"warn" → warn-and-continue');
|
|
84
|
+
it.todo('emit envelope with schemaVersion below advertised floor under strictness:"strict" → refuse unknown_schema_version');
|
|
85
|
+
it.todo('emit envelope with schemaVersion ABOVE advertised floor → refuse regardless of strictness');
|
|
86
|
+
it.todo('drift logs include envelope_schema_version_drift attribute on the OTel span');
|
|
87
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.trustBoundaryPropagation — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
|
|
5
|
+
* 2026-05-17 as DRAFT v1.x. Behavioral assertions stay `it.todo()` until a
|
|
6
|
+
* reference host wires the MCP-tool-result → envelope → RunEventDoc trust path.
|
|
7
|
+
*
|
|
8
|
+
* Summary: when a node consumes content from an untrusted source (MCP tool
|
|
9
|
+
* result per `mcp-integration.md`, A2A inbound message per `a2a-integration.md`),
|
|
10
|
+
* any envelope it subsequently emits whose payload incorporates that content
|
|
11
|
+
* MUST carry `meta.contentTrust: "untrusted"`. The engine MUST propagate this
|
|
12
|
+
* onto every `RunEventDoc` emitted as a consequence (`RunEventDoc.contentTrust
|
|
13
|
+
* = "untrusted"`). Downstream LLM nodes re-consuming these events MUST treat
|
|
14
|
+
* the content as untrusted per `SECURITY/threat-model-prompt-injection.md`.
|
|
15
|
+
* Approval gates MUST refuse to advance on `untrusted` envelopes with refusal
|
|
16
|
+
* code `untrusted_content_blocks_approval`.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v1/ai-envelope.md §"Trust boundary"
|
|
19
|
+
* @see spec/v1/mcp-integration.md §"Trust boundary"
|
|
20
|
+
* @see spec/v1/a2a-integration.md §"Trust boundary"
|
|
21
|
+
* @see SECURITY/threat-model-prompt-injection.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
|
|
27
|
+
interface DiscoveryDoc {
|
|
28
|
+
capabilities?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readMcpTrustBoundary(): Promise<string | null> {
|
|
32
|
+
const res = await driver.get('/.well-known/openwop');
|
|
33
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
34
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
35
|
+
const mcp = top && typeof top === 'object' ? (top['mcpClient'] as Record<string, unknown> | undefined) : undefined;
|
|
36
|
+
if (!mcp || typeof mcp !== 'object') return null;
|
|
37
|
+
const tb = mcp['trustBoundary'];
|
|
38
|
+
return typeof tb === 'string' ? tb : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('aiEnvelope.trustBoundaryPropagation: advertisement shape (FINAL v1.1)', () => {
|
|
42
|
+
it('hosts advertising mcpClient declare trustBoundary as "untrusted"', async () => {
|
|
43
|
+
const tb = await readMcpTrustBoundary();
|
|
44
|
+
if (tb === null) return; // host doesn't advertise mcpClient — skip
|
|
45
|
+
expect(
|
|
46
|
+
tb,
|
|
47
|
+
driver.describe(
|
|
48
|
+
'mcp-integration.md §"Trust boundary"',
|
|
49
|
+
'mcpClient.trustBoundary MUST be "untrusted" — MCP tool results are always untrusted input',
|
|
50
|
+
),
|
|
51
|
+
).toBe('untrusted');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; normalizedMeta?: { contentTrust?: string } } }> {
|
|
56
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
|
|
57
|
+
return { status: res.status, body: res.json as { status?: string; normalizedMeta?: { contentTrust?: string } } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
|
|
61
|
+
|
|
62
|
+
describe('aiEnvelope.trustBoundaryPropagation: behavioral normalization (FINAL v1.1)', () => {
|
|
63
|
+
it('envelope with meta.contentTrust:"untrusted" → normalizedMeta.contentTrust:"untrusted"', async () => {
|
|
64
|
+
const r = await accept({
|
|
65
|
+
type: 'clarification.request',
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
envelopeId: 'env-tb-1',
|
|
68
|
+
correlationId: 'r:n:0:tb1',
|
|
69
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
70
|
+
meta: { ...baseMeta, contentTrust: 'untrusted' },
|
|
71
|
+
});
|
|
72
|
+
if (r.status === 404) return;
|
|
73
|
+
expect(r.body.status).toBe('accepted');
|
|
74
|
+
expect(
|
|
75
|
+
r.body.normalizedMeta?.contentTrust,
|
|
76
|
+
driver.describe(
|
|
77
|
+
'ai-envelope.md §"Trust boundary"',
|
|
78
|
+
'envelope-supplied contentTrust:"untrusted" MUST propagate to normalizedMeta',
|
|
79
|
+
),
|
|
80
|
+
).toBe('untrusted');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('envelope with no meta.contentTrust + runTrustBoundary:"untrusted" → normalizedMeta.contentTrust:"untrusted" (run-level propagation)', async () => {
|
|
84
|
+
const r = await accept(
|
|
85
|
+
{
|
|
86
|
+
type: 'clarification.request',
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
envelopeId: 'env-tb-2',
|
|
89
|
+
correlationId: 'r:n:0:tb2',
|
|
90
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
91
|
+
meta: baseMeta,
|
|
92
|
+
},
|
|
93
|
+
{ runTrustBoundary: 'untrusted' },
|
|
94
|
+
);
|
|
95
|
+
if (r.status === 404) return;
|
|
96
|
+
expect(r.body.normalizedMeta?.contentTrust).toBe('untrusted');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('envelope-supplied contentTrust takes precedence over runTrustBoundary (per-emission decision)', async () => {
|
|
100
|
+
const r = await accept(
|
|
101
|
+
{
|
|
102
|
+
type: 'clarification.request',
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
envelopeId: 'env-tb-3',
|
|
105
|
+
correlationId: 'r:n:0:tb3',
|
|
106
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
107
|
+
meta: { ...baseMeta, contentTrust: 'trusted' },
|
|
108
|
+
},
|
|
109
|
+
{ runTrustBoundary: 'untrusted' }, // explicit conflict — envelope wins
|
|
110
|
+
);
|
|
111
|
+
if (r.status === 404) return;
|
|
112
|
+
expect(
|
|
113
|
+
r.body.normalizedMeta?.contentTrust,
|
|
114
|
+
driver.describe(
|
|
115
|
+
'ai-envelope.md §"Trust boundary"',
|
|
116
|
+
'per-emission contentTrust MUST take precedence — trusted envelope emitted after MCP tool result does NOT inherit untrusted',
|
|
117
|
+
),
|
|
118
|
+
).toBe('trusted');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('no contentTrust + no runTrustBoundary → default "trusted"', async () => {
|
|
122
|
+
const r = await accept({
|
|
123
|
+
type: 'clarification.request',
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
envelopeId: 'env-tb-default',
|
|
126
|
+
correlationId: 'r:n:0:tbdef',
|
|
127
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
128
|
+
meta: baseMeta,
|
|
129
|
+
});
|
|
130
|
+
if (r.status === 404) return;
|
|
131
|
+
expect(r.body.normalizedMeta?.contentTrust).toBe('trusted');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('aiEnvelope.trustBoundaryPropagation: engine-integration placeholders', () => {
|
|
136
|
+
// These require the engine to project normalizedMeta.contentTrust
|
|
137
|
+
// onto RunEventDoc.contentTrust + enforce the approval-gate refusal
|
|
138
|
+
// path. The pure-function acceptor surfaces normalizedMeta; engine
|
|
139
|
+
// wiring is host-impl scope.
|
|
140
|
+
it.todo('engine projects normalizedMeta.contentTrust onto RunEventDoc.contentTrust');
|
|
141
|
+
it.todo('approval gate refuses to advance on untrusted envelope with untrusted_content_blocks_approval');
|
|
142
|
+
it.todo('downstream LLM node re-consuming untrusted RunEventDoc applies <UNTRUSTED> wrap per prompt-injection invariant');
|
|
143
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.universalKinds — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
|
|
5
|
+
* 2026-05-17 as DRAFT v1.x. This scenario asserts the advertisement shape
|
|
6
|
+
* for hosts that opt into the new envelope-contracts surface
|
|
7
|
+
* (`capabilities.envelopeContracts.advertised: true`) and keeps the deeper
|
|
8
|
+
* behavioral assertions as `it.todo()` until a reference host wires the
|
|
9
|
+
* accept path.
|
|
10
|
+
*
|
|
11
|
+
* Summary: hosts MUST advertise the four universal kinds (`clarification.request`,
|
|
12
|
+
* `schema.request`, `schema.response`, `error`) in `capabilities.supportedEnvelopes`
|
|
13
|
+
* once they opt in. Universals are always-allowed; Envelope Contract gates MUST NOT
|
|
14
|
+
* refuse them.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v1/ai-envelope.md §"Universal kinds"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { driver } from '../lib/driver.js';
|
|
21
|
+
|
|
22
|
+
interface DiscoveryDoc {
|
|
23
|
+
capabilities?: Record<string, unknown>;
|
|
24
|
+
supportedEnvelopes?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UNIVERSALS = ['clarification.request', 'schema.request', 'schema.response', 'error'] as const;
|
|
28
|
+
|
|
29
|
+
async function readEnvelopeContracts(): Promise<{ advertised: boolean } | null> {
|
|
30
|
+
const res = await driver.get('/.well-known/openwop');
|
|
31
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
32
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
33
|
+
const block = top && typeof top === 'object' ? (top['envelopeContracts'] as Record<string, unknown> | undefined) : undefined;
|
|
34
|
+
if (!block || typeof block !== 'object') return null;
|
|
35
|
+
return { advertised: block['advertised'] === true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readSupportedEnvelopes(): Promise<string[] | null> {
|
|
39
|
+
const res = await driver.get('/.well-known/openwop');
|
|
40
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
41
|
+
// `supportedEnvelopes` is required v1 at the top level of the discovery payload
|
|
42
|
+
// per capabilities.schema.json. Some hosts nest it under `capabilities`.
|
|
43
|
+
const top = body?.supportedEnvelopes ?? (body?.capabilities as { supportedEnvelopes?: string[] } | undefined)?.supportedEnvelopes;
|
|
44
|
+
return Array.isArray(top) ? top : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('aiEnvelope.universalKinds: advertisement shape (FINAL v1.1)', () => {
|
|
48
|
+
it('capabilities.envelopeContracts is either absent or a well-formed object', async () => {
|
|
49
|
+
const block = await readEnvelopeContracts();
|
|
50
|
+
if (block === null) return; // host doesn't opt in — skip
|
|
51
|
+
expect(
|
|
52
|
+
typeof block.advertised,
|
|
53
|
+
driver.describe(
|
|
54
|
+
'ai-envelope.md §"Capability handshake integration"',
|
|
55
|
+
'capabilities.envelopeContracts.advertised MUST be a boolean when present',
|
|
56
|
+
),
|
|
57
|
+
).toBe('boolean');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('opted-in hosts advertise every universal kind in supportedEnvelopes', async () => {
|
|
61
|
+
const block = await readEnvelopeContracts();
|
|
62
|
+
if (block === null || !block.advertised) return; // not opted in — skip
|
|
63
|
+
const advertised = await readSupportedEnvelopes();
|
|
64
|
+
expect(
|
|
65
|
+
Array.isArray(advertised),
|
|
66
|
+
driver.describe(
|
|
67
|
+
'capabilities.schema.json §supportedEnvelopes',
|
|
68
|
+
'supportedEnvelopes MUST be present as an array on hosts that advertise envelopeContracts',
|
|
69
|
+
),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
for (const kind of UNIVERSALS) {
|
|
72
|
+
expect(
|
|
73
|
+
advertised!.includes(kind),
|
|
74
|
+
driver.describe(
|
|
75
|
+
'ai-envelope.md §"Universal kinds"',
|
|
76
|
+
`supportedEnvelopes MUST include "${kind}" — universals are always-allowed`,
|
|
77
|
+
),
|
|
78
|
+
).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Behavioral assertions through the workflow-engine sample's env-gated
|
|
84
|
+
// `POST /v1/host/sample/envelope/accept` seam (the RFC 0021 §A
|
|
85
|
+
// AIEnvelopeAcceptor reference implementation at
|
|
86
|
+
// `apps/workflow-engine/backend/typescript/src/host/envelopeAcceptor.ts`).
|
|
87
|
+
// Each test soft-skips on HTTP 404 (host doesn't expose the seam) so
|
|
88
|
+
// non-sample hosts keep the advertisement-shape coverage above.
|
|
89
|
+
async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; reason?: string; details?: unknown[]; envelopeId?: string } }> {
|
|
90
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
|
|
91
|
+
return { status: res.status, body: res.json as { status?: string; reason?: string; details?: unknown[]; envelopeId?: string } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
|
|
95
|
+
|
|
96
|
+
describe('aiEnvelope.universalKinds: behavioral accept via /v1/host/sample/envelope/accept (FINAL v1.1)', () => {
|
|
97
|
+
it('accept clarification.request with valid payload → status: accepted', async () => {
|
|
98
|
+
const r = await accept({
|
|
99
|
+
type: 'clarification.request',
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
envelopeId: 'env-uk-clar',
|
|
102
|
+
correlationId: 'r:n:0:clar',
|
|
103
|
+
payload: { questions: [{ id: 'q1', question: 'Which provider?' }] },
|
|
104
|
+
meta: baseMeta,
|
|
105
|
+
});
|
|
106
|
+
if (r.status === 404) return;
|
|
107
|
+
expect(r.body.status, driver.describe('ai-envelope.md §"Universal kinds"', 'valid clarification.request MUST be accepted')).toBe('accepted');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('accept schema.request → status: accepted', async () => {
|
|
111
|
+
const r = await accept({
|
|
112
|
+
type: 'schema.request',
|
|
113
|
+
schemaVersion: 1,
|
|
114
|
+
envelopeId: 'env-uk-sr',
|
|
115
|
+
correlationId: 'r:n:0:sr',
|
|
116
|
+
payload: { envelopeType: 'vendor.acme.prd.create' },
|
|
117
|
+
meta: baseMeta,
|
|
118
|
+
});
|
|
119
|
+
if (r.status === 404) return;
|
|
120
|
+
expect(r.body.status).toBe('accepted');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('accept schema.response (ack:true) → status: accepted', async () => {
|
|
124
|
+
const r = await accept({
|
|
125
|
+
type: 'schema.response',
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
envelopeId: 'env-uk-sresp',
|
|
128
|
+
correlationId: 'r:n:0:sresp',
|
|
129
|
+
payload: { envelopeType: 'vendor.acme.prd.create', ack: true },
|
|
130
|
+
meta: baseMeta,
|
|
131
|
+
});
|
|
132
|
+
if (r.status === 404) return;
|
|
133
|
+
expect(r.body.status).toBe('accepted');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accept error envelope (LLM-emitted) → status: accepted (distinct from host-level ErrorEnvelope)', async () => {
|
|
137
|
+
const r = await accept({
|
|
138
|
+
type: 'error',
|
|
139
|
+
schemaVersion: 1,
|
|
140
|
+
envelopeId: 'env-uk-err',
|
|
141
|
+
correlationId: 'r:n:0:err',
|
|
142
|
+
payload: { code: 'validation_failed', message: 'I cannot produce JSON matching that schema' },
|
|
143
|
+
meta: baseMeta,
|
|
144
|
+
});
|
|
145
|
+
if (r.status === 404) return;
|
|
146
|
+
expect(r.body.status, driver.describe('ai-envelope.md §error', 'LLM-emitted error envelope MUST be accepted (NOT the host HTTP ErrorEnvelope)')).toBe('accepted');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('refuse invalid clarification.request (missing questions[]) → status: invalid', async () => {
|
|
150
|
+
const r = await accept({
|
|
151
|
+
type: 'clarification.request',
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
envelopeId: 'env-uk-bad',
|
|
154
|
+
correlationId: 'r:n:0:bad',
|
|
155
|
+
payload: { contextType: 'form-field' }, // missing required `questions`
|
|
156
|
+
meta: baseMeta,
|
|
157
|
+
});
|
|
158
|
+
if (r.status === 404) return;
|
|
159
|
+
expect(
|
|
160
|
+
r.body.status,
|
|
161
|
+
driver.describe('ai-envelope.md §"Schema discipline"', 'malformed payload MUST be rejected with invalid'),
|
|
162
|
+
).toBe('invalid');
|
|
163
|
+
expect(Array.isArray(r.body.details), 'invalid outcome MUST carry validation details').toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('aiEnvelope.universalKinds: engine-integration placeholders', () => {
|
|
168
|
+
// These assert behaviors beyond the pure-function acceptor — they
|
|
169
|
+
// need the engine to lift envelopes into interrupts / re-inject
|
|
170
|
+
// schemas / emit log.appended events. Tracked separately; the
|
|
171
|
+
// acceptor seam above covers the 5 wire-level assertions.
|
|
172
|
+
it.todo('lift clarification.request to kind:"clarification" interrupt per interrupt.md');
|
|
173
|
+
it.todo('schema.request triggers next-turn schema re-injection (host responsibility)');
|
|
174
|
+
it.todo('schema.response counted (or exempt) against limits.envelopesPerTurn per host policy');
|
|
175
|
+
it.todo('error envelope projects to log.appended (level: "error"), NOT node.failed');
|
|
176
|
+
});
|
|
@@ -26,11 +26,21 @@ const SKIP = !FIXTURE;
|
|
|
26
26
|
interface ChannelWrittenPayload {
|
|
27
27
|
channel?: string;
|
|
28
28
|
value?: unknown;
|
|
29
|
+
/**
|
|
30
|
+
* Per `channel-written-payload.schema.json` — present on inbound
|
|
31
|
+
* cross-engine writes per `channels-and-reducers.md §"Across
|
|
32
|
+
* engines"`. When ANY event in the run carries this field, the
|
|
33
|
+
* cross-engine assertions (CF-8) MUST hold in addition to the
|
|
34
|
+
* intra-engine ordering rule.
|
|
35
|
+
*/
|
|
36
|
+
sourceEngineId?: string;
|
|
37
|
+
sourceRunId?: string;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
interface RunEvent {
|
|
32
41
|
type: string;
|
|
33
42
|
sequence: number;
|
|
43
|
+
eventId?: string;
|
|
34
44
|
payload?: ChannelWrittenPayload;
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -74,6 +84,40 @@ describe.skipIf(SKIP)('append-ordering: folded channel reflects event sequence',
|
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
// CF-8: cross-engine ordering rule. When ANY channel.written
|
|
88
|
+
// event carries `sourceEngineId`, the owner-engine MUST have
|
|
89
|
+
// assigned the recorded `sequence` at append time and the event
|
|
90
|
+
// log MUST remain strictly monotonic regardless of source. The
|
|
91
|
+
// intra-engine check above already verifies monotonicity per
|
|
92
|
+
// channel; here we additionally verify (1) at least one cross-
|
|
93
|
+
// engine and one own-engine write coexist correctly, and (2) any
|
|
94
|
+
// tie in the secondary `(sequence, eventId)` order is broken
|
|
95
|
+
// deterministically (no two events share both fields).
|
|
96
|
+
const allWrites = Array.from(byChannel.values()).flat();
|
|
97
|
+
const crossEngineWrites = allWrites.filter(
|
|
98
|
+
(e) => typeof e.payload?.sourceEngineId === 'string',
|
|
99
|
+
);
|
|
100
|
+
if (crossEngineWrites.length > 0) {
|
|
101
|
+
// Property: every cross-engine event carries BOTH sourceEngineId
|
|
102
|
+
// AND sourceRunId (per channel-written-payload.schema.json).
|
|
103
|
+
for (const e of crossEngineWrites) {
|
|
104
|
+
expect(typeof e.payload?.sourceRunId, driver.describe(
|
|
105
|
+
'channel-written-payload.schema.json',
|
|
106
|
+
'cross-engine writes MUST carry sourceRunId alongside sourceEngineId',
|
|
107
|
+
)).toBe('string');
|
|
108
|
+
}
|
|
109
|
+
// Property: (sequence, eventId) is a total order — no duplicates.
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
for (const e of allWrites) {
|
|
112
|
+
const key = `${e.sequence}::${e.eventId ?? ''}`;
|
|
113
|
+
expect(seen.has(key), driver.describe(
|
|
114
|
+
'channels-and-reducers.md §"Tie-breaking when sequences collide"',
|
|
115
|
+
`(sequence, eventId) MUST be unique across the run — duplicate found: ${key}`,
|
|
116
|
+
)).toBe(false);
|
|
117
|
+
seen.add(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
77
121
|
// Cross-check against the projected channel state on the run snapshot
|
|
78
122
|
// (when surfaced) — projected array length MUST equal the number of writes.
|
|
79
123
|
const snapshot = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|