@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2
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/README.md +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §D.1 — `negotiation-authenticated` (suite 2.0.0, target major 2; gated on a2a/mcp + seams).
|
|
3
|
+
*
|
|
4
|
+
* A version-negotiation exchange MUST be authenticated: the peer identity is
|
|
5
|
+
* the caller's Subject or the host's own outbound identity, and an
|
|
6
|
+
* UNAUTHENTICATED exchange MUST NOT lower the negotiated version below
|
|
7
|
+
* `preferredVersion` (`spec/v2/core/interop.md` §Authentication; RFC 0175 §D.1,
|
|
8
|
+
* row C8.6; invariant `interop-negotiation-authenticated`).
|
|
9
|
+
*
|
|
10
|
+
* The load-bearing requirement is negative, as in the v1 silent-downgrade
|
|
11
|
+
* scenarios: an unauthenticated peer that offers only a lower version must
|
|
12
|
+
* produce either a refusal or a negotiation at `preferredVersion` — never a
|
|
13
|
+
* success at the lower version. Both legs are checked: the host's report
|
|
14
|
+
* (`negotiatedVersion`), the wire the suite's peer captured (`A2A-Version` /
|
|
15
|
+
* `MCP-Protocol-Version` on every call), and, where the seam names the run, the
|
|
16
|
+
* `negotiation.decided` event (`outcome: refused`, `reason: unauthenticated`).
|
|
17
|
+
*
|
|
18
|
+
* Driven through the §22/§23 invoke seams with `authenticated: false` and a
|
|
19
|
+
* peer that offers only the lower era the suite can speak. When the host's
|
|
20
|
+
* `preferredVersion` is already the lowest version the suite can offer there
|
|
21
|
+
* is nothing lower to offer and the leg records `blocked`.
|
|
22
|
+
*
|
|
23
|
+
* Callback-shaped (the host calls the suite's peer): unwitnessable when the
|
|
24
|
+
* host is in a separate network namespace — `../lib/host-callback.ts`.
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
27
|
+
* @see SECURITY/threat-model-interop.md (interop-negotiation-authenticated)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
33
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
34
|
+
import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
35
|
+
import { McpFakeServer } from '../lib/mcp-fake-server.js';
|
|
36
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
|
|
39
|
+
export const REQUIRES_HOST_CALLBACK = "the host's A2A/MCP client calls a suite-owned peer offering only a lower version, under an unauthenticated principal";
|
|
40
|
+
|
|
41
|
+
const A2A_LOWER = '0.3';
|
|
42
|
+
const MCP_LOWER = '2025-06-18';
|
|
43
|
+
|
|
44
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
45
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EventRow { type?: unknown; payload?: Record<string, unknown> }
|
|
49
|
+
async function decidedEvents(runId: string): Promise<EventRow[]> {
|
|
50
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
51
|
+
const events = res.status === 200 ? ((res.json as { events?: EventRow[] } | null)?.events ?? []) : [];
|
|
52
|
+
return events.filter((e) => e.type === 'negotiation.decided');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function assertNotLowered(id: string, preferred: string, lower: string, res: { status: number; json: unknown }, wireVersions: string[]): Promise<void> {
|
|
56
|
+
if (res.status < 400) {
|
|
57
|
+
const negotiated = (res.json as { negotiatedVersion?: unknown } | null)?.negotiatedVersion;
|
|
58
|
+
expect(
|
|
59
|
+
negotiated,
|
|
60
|
+
req(id, 'interop.md §Authentication', `an unauthenticated exchange MUST NOT lower the version below preferredVersion (${preferred}) — the host reports ${String(negotiated)} (RFC 0175 §D.1)`),
|
|
61
|
+
).toBe(preferred);
|
|
62
|
+
}
|
|
63
|
+
for (const v of wireVersions) {
|
|
64
|
+
expect(
|
|
65
|
+
v,
|
|
66
|
+
req(id, 'interop.md §Authentication', `the wire toward the peer MUST NOT carry the lower version (${lower}) under an unauthenticated exchange — captured ${v}`),
|
|
67
|
+
).not.toBe(lower);
|
|
68
|
+
}
|
|
69
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
70
|
+
if (typeof runId !== 'string') {
|
|
71
|
+
softSkip('blocked', 'the seam answered without a runId — the negotiation.decided event for the unauthenticated exchange is not addressable from the suite');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const decided = await decidedEvents(runId);
|
|
75
|
+
expect(decided.length, req(id, 'interop.md §The audit event', 'the unauthenticated exchange MUST also emit negotiation.decided (every outcome, RFC 0175 §D.3)')).toBeGreaterThan(0);
|
|
76
|
+
const last = decided[decided.length - 1]!.payload ?? {};
|
|
77
|
+
if (res.status >= 400) {
|
|
78
|
+
expect(last['outcome'], req(id, 'run-event-payloads.schema.json negotiationDecided.outcome', 'a refused unauthenticated exchange records outcome: refused')).toBe('refused');
|
|
79
|
+
expect(['unauthenticated', 'below-floor', 'unsupported'], req(id, 'run-event-payloads.schema.json negotiationDecided.reason', 'the reason names why (unauthenticated when the floor was not the cause)')).toContain(last['reason']);
|
|
80
|
+
} else {
|
|
81
|
+
expect(last['version'], req(id, 'interop.md §The audit event', 'an accepted unauthenticated exchange records the preferred version — nothing lower')).toBe(preferred);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams)', () => {
|
|
86
|
+
it('an unauthenticated A2A exchange cannot lower the version below preferredVersion', async () => {
|
|
87
|
+
const doc = await discovery();
|
|
88
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
|
+
const facet = await familyAdvertised('a2a');
|
|
90
|
+
if (!facet) return softSkip('inapplicable', 'a2a facet not advertised — no negotiation to authenticate');
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the unauthenticated exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
92
|
+
const preferred = String(facet['preferredVersion']);
|
|
93
|
+
if (preferred === A2A_LOWER) return softSkip('blocked', `a2a.preferredVersion is ${preferred}, the lowest version the suite peer can offer — no lower version can be offered`);
|
|
94
|
+
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
95
|
+
await peer.start();
|
|
96
|
+
try {
|
|
97
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.endpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
|
|
98
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
|
|
99
|
+
const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
|
|
100
|
+
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
|
|
101
|
+
} finally {
|
|
102
|
+
await peer.stop();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('an unauthenticated MCP exchange cannot lower the revision below preferredVersion', async () => {
|
|
107
|
+
const doc = await discovery();
|
|
108
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
109
|
+
const facet = await familyAdvertised('mcp');
|
|
110
|
+
if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no negotiation to authenticate');
|
|
111
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the unauthenticated exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
112
|
+
const preferred = String(facet['preferredVersion']);
|
|
113
|
+
if (!(MCP_LOWER < preferred)) return softSkip('blocked', `mcp.preferredVersion is ${preferred}, at or below the lowest revision the suite server can offer (${MCP_LOWER}) — no lower revision can be offered`);
|
|
114
|
+
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
115
|
+
await server.start();
|
|
116
|
+
try {
|
|
117
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.endpoint(), authenticated: false });
|
|
118
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
119
|
+
const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
|
|
120
|
+
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
|
|
121
|
+
} finally {
|
|
122
|
+
await server.stop();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §D.3 — `negotiation-decided-emitted` (suite 2.0.0, target major 2; gated on a2a/mcp + seams).
|
|
3
|
+
*
|
|
4
|
+
* Every negotiation outcome — including the refused one — MUST emit a
|
|
5
|
+
* content-free `negotiation.decided` event on the host's own event log
|
|
6
|
+
* (`schemas/v2/run-event-payloads.schema.json#/$defs/negotiationDecided`:
|
|
7
|
+
* `{ protocol, outcome, version?, floor?, peerDigest?, reason?, at }`, the peer
|
|
8
|
+
* as a SHA-256 digest of its origin, never in clear). The event on the host's
|
|
9
|
+
* log is the NORMATIVE witness of the silent-downgrade invariants; the seams
|
|
10
|
+
* profile drives the exchange (`spec/v2/core/interop.md` §The audit event;
|
|
11
|
+
* RFC 0175 §D.3/§D.5, row C8.5).
|
|
12
|
+
*
|
|
13
|
+
* How the exchange is driven: exactly as the v1 versioned-composition
|
|
14
|
+
* scenarios do (`a2a-version-negotiation.test.ts`, `mcp-version-negotiation.test.ts`)
|
|
15
|
+
* — the host's real client path is pointed at the suite's fake peer / server
|
|
16
|
+
* through the §22/§23 invoke seams (`/conformance/seams/sample/{a2a,mcp}/invoke`
|
|
17
|
+
* under the v2 seams profile). The seam's `200` answers with the run whose log
|
|
18
|
+
* carries the event (`runId`); the event is then read on the normative surface
|
|
19
|
+
* (`GET /runs/{runId}/events/poll`). A seam answer without a `runId` leaves the
|
|
20
|
+
* log unaddressable and records `blocked`.
|
|
21
|
+
*
|
|
22
|
+
* Callback-shaped (the host calls the suite's peer): unwitnessable when the
|
|
23
|
+
* host is in a separate network namespace — `../lib/host-callback.ts`.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
33
|
+
import addFormats from 'ajv-formats';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
36
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
39
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
40
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
41
|
+
import { req } from '../lib/requirement-ids.js';
|
|
42
|
+
|
|
43
|
+
export const REQUIRES_HOST_CALLBACK = "the host's A2A/MCP client calls the suite's fake peer/server through the invoke seams; the negotiation.decided event is then read on the host's own log";
|
|
44
|
+
|
|
45
|
+
const EVENT = 'negotiation.decided';
|
|
46
|
+
|
|
47
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
48
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ajv over the `negotiationDecided` payload definition (self-contained — no $refs). */
|
|
52
|
+
function payloadValidator(): ((p: unknown) => { ok: boolean; errors: string }) | null {
|
|
53
|
+
const path = join(SCHEMAS_DIR, 'v2', 'run-event-payloads.schema.json');
|
|
54
|
+
let def: unknown;
|
|
55
|
+
try {
|
|
56
|
+
def = (JSON.parse(readFileSync(path, 'utf8')) as { $defs?: Record<string, unknown> }).$defs?.['negotiationDecided'];
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
if (!def || typeof def !== 'object') return null;
|
|
59
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
60
|
+
addFormats(ajv);
|
|
61
|
+
const validate = ajv.compile(def as Record<string, unknown>);
|
|
62
|
+
return (p) => ({ ok: validate(p) as boolean, errors: ajv.errorsText(validate.errors, { separator: '; ' }) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EventRow { type?: unknown; payload?: Record<string, unknown>; sequence?: unknown }
|
|
66
|
+
async function eventsOf(runId: string): Promise<EventRow[]> {
|
|
67
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
68
|
+
return res.status === 200 ? ((res.json as { events?: EventRow[] } | null)?.events ?? []) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drive one exchange; returns the seam response or null with the reason recorded. */
|
|
72
|
+
async function drive(protocol: 'a2a' | 'mcp', peerUrl: string): Promise<{ status: number; body: Record<string, unknown> } | null> {
|
|
73
|
+
const path = `${SEAMS_PREFIX}/sample/${protocol}/invoke`;
|
|
74
|
+
const res = await driver.post(path, protocol === 'a2a' ? { peerUrl } : { serverUrl: peerUrl });
|
|
75
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) {
|
|
76
|
+
seamAbsent(`host advertises ${protocol} but the invoke seam ${path} answered ${res.status} — the exchange cannot be driven (host-sample-test-seams.md §22/§23)`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { status: res.status, body: (res.json ?? {}) as Record<string, unknown> };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function originDigest(url: string): string {
|
|
83
|
+
return createHash('sha256').update(new URL(url).origin, 'utf8').digest('hex');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
87
|
+
const doc = await discovery();
|
|
88
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
|
+
const facet = await familyAdvertised(protocol);
|
|
90
|
+
if (!facet) return softSkip('inapplicable', `${protocol} facet not advertised — no negotiation to audit`);
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', `the exchange is driven through the seams profile (RFC 0175 §D.3) — conformance.seamsProfile is not openwop-conformance-seams-v2`);
|
|
92
|
+
const peer = protocol === 'a2a' ? getA2AFakePeer() : getMcpFakeServer();
|
|
93
|
+
if (peer === null) return softSkip('blocked', `the suite ${protocol === 'a2a' ? 'A2A fake peer (OPENWOP_A2A_FAKE_PEER=true)' : 'MCP fake server (OPENWOP_MCP_FAKE_SERVER=true)'} is not started in this run`);
|
|
94
|
+
peer.reset();
|
|
95
|
+
const validate = payloadValidator();
|
|
96
|
+
if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
|
|
97
|
+
|
|
98
|
+
const driven = await drive(protocol, peer.endpoint());
|
|
99
|
+
if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
|
|
100
|
+
const runId = driven.body['runId'];
|
|
101
|
+
if (typeof runId !== 'string') {
|
|
102
|
+
return softSkip('blocked', `the ${protocol} invoke seam answered ${driven.status} without a runId — the host log that carries ${EVENT} is not addressable from the suite (the seam MUST name the run whose log recorded the decision)`);
|
|
103
|
+
}
|
|
104
|
+
const events = await eventsOf(runId);
|
|
105
|
+
const decided = events.filter((e) => e.type === EVENT);
|
|
106
|
+
expect(
|
|
107
|
+
decided.length,
|
|
108
|
+
req(id, 'interop.md §The audit event', `every ${protocol} negotiation outcome MUST emit ${EVENT} on the host's own event log (RFC 0175 §D.3) — none found on run ${runId}`),
|
|
109
|
+
).toBeGreaterThan(0);
|
|
110
|
+
const outcomeExpected = driven.status < 400 ? 'accepted' : 'refused';
|
|
111
|
+
for (const e of decided) {
|
|
112
|
+
const p = e.payload ?? {};
|
|
113
|
+
const check = validate(p);
|
|
114
|
+
expect(check.ok, req(id, 'run-event-payloads.schema.json negotiationDecided', `the payload MUST validate: ${check.errors}`)).toBe(true);
|
|
115
|
+
expect(p['protocol'], req(id, 'run-event-payloads.schema.json negotiationDecided.protocol', `protocol MUST be ${protocol}`)).toBe(protocol);
|
|
116
|
+
if (typeof p['peerDigest'] === 'string') {
|
|
117
|
+
expect(
|
|
118
|
+
p['peerDigest'],
|
|
119
|
+
req(id, 'interop.md §The audit event', 'peerDigest MUST be the SHA-256 of the peer origin — never the origin in clear (RFC 0175 §D.3)'),
|
|
120
|
+
).toBe(originDigest(peer.endpoint()));
|
|
121
|
+
}
|
|
122
|
+
for (const v of Object.values(p)) {
|
|
123
|
+
expect(
|
|
124
|
+
typeof v === 'string' && v.includes(new URL(peer.endpoint()).host),
|
|
125
|
+
req(id, 'interop.md §The audit event', 'the event is content-free: the peer origin MUST NOT appear in clear in any field'),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const last = decided[decided.length - 1]!.payload ?? {};
|
|
130
|
+
expect(
|
|
131
|
+
last['outcome'],
|
|
132
|
+
req(id, 'run-event-payloads.schema.json negotiationDecided.outcome', `the seam answered ${driven.status}, so the recorded outcome MUST be ${outcomeExpected}`),
|
|
133
|
+
).toBe(outcomeExpected);
|
|
134
|
+
if (driven.status < 400) {
|
|
135
|
+
expect(
|
|
136
|
+
last['version'],
|
|
137
|
+
req(id, 'interop.md §The audit event', 'an accepted negotiation records the version the host actually used, which MUST equal the seam-reported negotiatedVersion'),
|
|
138
|
+
).toBe(driven.body['negotiatedVersion']);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('RFC 0175 §D.3 — negotiation-decided-emitted (gated on a2a/mcp + seams)', () => {
|
|
143
|
+
it('an A2A exchange leaves negotiation.decided on the host log', async () => {
|
|
144
|
+
await leg('a2a', 'openwop.requirement.0175.negotiation-decided-emitted');
|
|
145
|
+
});
|
|
146
|
+
it('an MCP exchange leaves negotiation.decided on the host log', async () => {
|
|
147
|
+
await leg('mcp', 'openwop.requirement.0175.negotiation-decided-emitted.mcp');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §A.1 / §B.1 — `no-transport-advertisement` (suite 2.0.0, target major 2; unaided).
|
|
3
|
+
*
|
|
4
|
+
* REST and SSE are the wire. A v2 host MUST NOT advertise a transport list:
|
|
5
|
+
* `supportedTransports` does not exist in `schemas/v2/capabilities.schema.json`
|
|
6
|
+
* (row C8.2), and the `grpc` block is removed from the closed root because an
|
|
7
|
+
* unwitnessable family is not advertisable (row C8.3; RFC 0169 §A.1). A2A and
|
|
8
|
+
* MCP are compositions advertised by their own facets (`spec/v2/core/interop.md`
|
|
9
|
+
* §REST is the wire, §gRPC).
|
|
10
|
+
*
|
|
11
|
+
* Unaided: the v2 representation of `/.well-known/openwop` is enough. The
|
|
12
|
+
* schema check (closed root) is the belt; the named-key checks are the braces,
|
|
13
|
+
* so a host whose document fails validation for some other reason still gets
|
|
14
|
+
* the specific finding.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v2/core/interop.md §REST is the wire, §gRPC
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { req } from '../lib/requirement-ids.js';
|
|
23
|
+
|
|
24
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
25
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('RFC 0175 §A.1/§B.1 — no-transport-advertisement (unaided)', () => {
|
|
29
|
+
it('the v2 root carries no supportedTransports and no grpc block', async () => {
|
|
30
|
+
const doc = await discovery();
|
|
31
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
32
|
+
expect(
|
|
33
|
+
'supportedTransports' in doc,
|
|
34
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §REST is the wire', 'a v2 host MUST NOT advertise a transport list — `supportedTransports` does not exist in v2 (RFC 0175 §B.1, row C8.2)'),
|
|
35
|
+
).toBe(false);
|
|
36
|
+
expect(
|
|
37
|
+
'grpc' in doc,
|
|
38
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §gRPC', 'a v2 host MUST NOT advertise a `grpc` capability block — an unwitnessable family is not advertisable (RFC 0175 §A.1, row C8.3)'),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
// Neither may hide inside a nested family: an `a2a.transports` or
|
|
41
|
+
// `mcp.supportedTransports` would be the same claim one level down.
|
|
42
|
+
for (const family of ['a2a', 'mcp']) {
|
|
43
|
+
const rec = doc[family];
|
|
44
|
+
if (rec && typeof rec === 'object') {
|
|
45
|
+
expect(
|
|
46
|
+
'supportedTransports' in (rec as Record<string, unknown>),
|
|
47
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §The facets', `${family} is a composition facet, not a transport advertisement — no supportedTransports inside it`),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const check = v2Validator('capabilities')(doc);
|
|
52
|
+
expect(
|
|
53
|
+
check.ok,
|
|
54
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'capabilities.schema.json (closed root)', `the v2 discovery document MUST validate against the closed root — a transport list or a grpc block fails it: ${check.errors}`),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `owner-subject-required` (suite 2.0.0; RFC 0170 §A.1;
|
|
3
|
+
* `spec/v2/core/identity.md` §1.1 "Shape").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `RunSnapshot.owner` is
|
|
6
|
+
* `{ tenant, workspace?, subject }` with `subject` REQUIRED and validating
|
|
7
|
+
* against `schemas/v2/subject.schema.json`; `principal` / `principalKind` are
|
|
8
|
+
* removed. The run is created through `POST /runs` with the smallest valid body
|
|
9
|
+
* (the `conformance-noop` fixture); a refusal to create records `blocked` with
|
|
10
|
+
* the host's envelope.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
15
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
16
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
17
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
18
|
+
import { req } from '../lib/requirement-ids.js';
|
|
19
|
+
|
|
20
|
+
const DOC = 'spec/v2/core/identity.md §1.1';
|
|
21
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
22
|
+
|
|
23
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
27
|
+
try { return await fn(); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Created = { readonly ok: true; readonly runId: string } | { readonly ok: false; readonly reason: string };
|
|
31
|
+
|
|
32
|
+
async function createRun(): Promise<Created> {
|
|
33
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
34
|
+
if (res === null) return { ok: false, reason: 'POST /runs unreachable (fetch failed)' };
|
|
35
|
+
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
36
|
+
if (res.status !== 201 || typeof runId !== 'string') {
|
|
37
|
+
return { ok: false, reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, runId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function snapshot(): Promise<{ owner: Record<string, unknown> } | { reason: string }> {
|
|
43
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
44
|
+
const created = await createRun();
|
|
45
|
+
if (!created.ok) return { reason: created.reason };
|
|
46
|
+
const res = await http(() => driver.get(`/runs/${encodeURIComponent(created.runId)}`));
|
|
47
|
+
if (res === null || res.status !== 200) return { reason: `GET /runs/{runId} answered ${res?.status ?? 'no response'} for the run just created` };
|
|
48
|
+
const owner = (res.json as { owner?: unknown } | undefined)?.owner;
|
|
49
|
+
if (owner === null || typeof owner !== 'object') return { reason: 'GET /runs/{runId} carries no `owner` object — RunSnapshot.owner is REQUIRED (schemas/v2/run-snapshot.schema.json)' };
|
|
50
|
+
return { owner: owner as Record<string, unknown> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('v2 owner-subject-required (RFC 0170 §A.1)', () => {
|
|
54
|
+
it('owner.subject is present and validates against subject.schema.json', async () => {
|
|
55
|
+
const s = await snapshot();
|
|
56
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
57
|
+
const validate = v2Validator('subject');
|
|
58
|
+
expect(s.owner['subject'], req('openwop.requirement.0170.owner-subject-required.subject', DOC, 'RunSnapshot.owner.subject is REQUIRED')).toBeDefined();
|
|
59
|
+
const r = validate(s.owner['subject']);
|
|
60
|
+
expect(r.ok, req('openwop.requirement.0170.owner-subject-required.subject', DOC, `owner.subject MUST validate against schemas/v2/subject.schema.json (issuer, subjectId, tenant, lane, kind; closed) (${r.errors})`)).toBe(true);
|
|
61
|
+
expect(typeof s.owner['tenant'], req('openwop.requirement.0170.owner-subject-required.subject', DOC, 'RunSnapshot.owner.tenant is REQUIRED')).toBe('string');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('owner carries no principal / principalKind', async () => {
|
|
65
|
+
const s = await snapshot();
|
|
66
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
67
|
+
expect(s.owner['principal'], req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, '`principal` is removed from RunSnapshot.owner — subject.subjectId carries it')).toBeUndefined();
|
|
68
|
+
expect(s.owner['principalKind'], req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, '`principalKind` is removed from RunSnapshot.owner — subject.kind carries it')).toBeUndefined();
|
|
69
|
+
const extra = Object.keys(s.owner).filter((k) => !['tenant', 'workspace', 'subject'].includes(k));
|
|
70
|
+
expect(extra, req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, 'owner is the closed block { tenant, workspace?, subject }')).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `packs` + `sandbox`).
|
|
3
|
+
*
|
|
4
|
+
* Isolation binds with pack EXECUTION: a host that executes third-party packs
|
|
5
|
+
* MUST enforce the eight `node-pack-sandbox-*` invariants and MUST advertise
|
|
6
|
+
* `sandbox.isolationModel ∈ wasm | process | container | vm`
|
|
7
|
+
* (`spec/v2/facets/sandbox.schema.json`); `node:vm` is not a value — it "is
|
|
8
|
+
* escapable by design" (RFC 0035 §130) and RFC 0173 §D.1 disposes of it
|
|
9
|
+
* (`spec/v2/core/security-defaults.md` §Sandbox isolation; RFC 0173 §B row C6.5).
|
|
10
|
+
*
|
|
11
|
+
* Legs:
|
|
12
|
+
* - unaided on the gate: the facet names a mechanism from the closed set and
|
|
13
|
+
* never `node:vm`;
|
|
14
|
+
* - the eight behavioural legs are seam-driven through the RFC 0035 §B
|
|
15
|
+
* misbehaving-pack registry (`host-sample-test-seams.md` §8,
|
|
16
|
+
* `POST /v1/host/sample/test/sandbox-invoke` → `/conformance/seams/sample/test/sandbox-invoke`
|
|
17
|
+
* under the v2 seams profile): fs read, fs write, env, network, process,
|
|
18
|
+
* timeout, memory cap, cross-pack context. Each is `blocked` unless the
|
|
19
|
+
* seams profile is advertised and the seam answers. `no-eval` stays
|
|
20
|
+
* reference-impl (`ext/sandbox-runtime-notes`, RFC 0173 §D.1) and is not a
|
|
21
|
+
* suite leg.
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/security-defaults.md §Sandbox isolation
|
|
24
|
+
* @see spec/v1/host-sample-test-seams.md §8
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
30
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
31
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
const ISOLATION_MODELS = ['wasm', 'process', 'container', 'vm'];
|
|
35
|
+
const INVOKE = `${SEAMS_PREFIX}/sample/test/sandbox-invoke`;
|
|
36
|
+
|
|
37
|
+
interface SandboxError { code?: string; details?: { escapeKind?: string; requestedCapability?: string; message?: string } }
|
|
38
|
+
interface InvokeResult { result?: unknown; error?: SandboxError }
|
|
39
|
+
|
|
40
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
41
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Gate for every leg: packs + sandbox advertised. Returns the sandbox facet or null (reason recorded). */
|
|
45
|
+
async function gated(): Promise<Record<string, unknown> | null> {
|
|
46
|
+
const doc = await discovery();
|
|
47
|
+
if (!doc) { softSkip('blocked', 'discovery unreachable'); return null; }
|
|
48
|
+
if (!(await gateFamily('packs'))) { softSkip('inapplicable', 'packs family not advertised — no pack execution, no isolation obligation (gate recorded under openwop.family.packs)'); return null; }
|
|
49
|
+
const sandbox = await gateFamily('sandbox');
|
|
50
|
+
if (!sandbox) { softSkip('inapplicable', 'sandbox family not advertised (gate recorded under openwop.family.sandbox)'); return null; }
|
|
51
|
+
return sandbox;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drive one misbehaving typeId through the sandbox seam; null (reason recorded) when the seam is absent. */
|
|
55
|
+
async function invoke(typeId: string, extra: Record<string, unknown> = {}): Promise<InvokeResult | null> {
|
|
56
|
+
const doc = await discovery();
|
|
57
|
+
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('blocked', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
58
|
+
const res = await driver.post(INVOKE, { typeId, ...extra });
|
|
59
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises packs + sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
60
|
+
expect(res.status, req('openwop.requirement.0173.pack-isolation.seam', 'host-sample-test-seams.md §8', `${INVOKE} MUST answer 200 { result } | 200 { error } for ${typeId}`)).toBe(200);
|
|
61
|
+
return (res.json ?? {}) as InvokeResult;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function expectEscape(id: string, r: InvokeResult, escapeKind: string, invariant: string): void {
|
|
65
|
+
expect(r.error?.code, req(id, 'security-defaults.md §Sandbox isolation', `${invariant}: the sandbox MUST refuse with sandbox_escape_attempt (RFC 0173 §B binds the invariant with pack execution)`)).toBe('sandbox_escape_attempt');
|
|
66
|
+
expect(r.error?.details?.escapeKind, req(id, 'host-sample-test-seams.md §8 SandboxError', `escapeKind MUST be ${escapeKind}`)).toBe(escapeKind);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('RFC 0173 §B — pack-isolation (gated on packs + sandbox)', () => {
|
|
70
|
+
it('sandbox.isolationModel names a real mechanism, never node:vm', async () => {
|
|
71
|
+
const sandbox = await gated();
|
|
72
|
+
if (!sandbox) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
73
|
+
const model = sandbox['isolationModel'];
|
|
74
|
+
expect(
|
|
75
|
+
model,
|
|
76
|
+
req('openwop.requirement.0173.pack-isolation', 'facets/sandbox.schema.json isolationModel', 'isolationModel MUST NOT be node:vm — it is escapable by design (RFC 0035 §130) and is not a v2 value (RFC 0173 §D.1)'),
|
|
77
|
+
).not.toBe('node:vm');
|
|
78
|
+
expect(
|
|
79
|
+
ISOLATION_MODELS,
|
|
80
|
+
req('openwop.requirement.0173.pack-isolation', 'facets/sandbox.schema.json isolationModel', `isolationModel MUST be one of ${ISOLATION_MODELS.join(' | ')} (got ${String(model)})`),
|
|
81
|
+
).toContain(model);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('node-pack-sandbox-fs-gated: a host filesystem read is refused', async () => {
|
|
85
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
86
|
+
const r = await invoke('misbehave.fs-escape-read');
|
|
87
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
88
|
+
expectEscape('openwop.requirement.0173.pack-isolation.fs-read', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('node-pack-sandbox-fs-gated: a host filesystem write is refused', async () => {
|
|
92
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
93
|
+
const r = await invoke('misbehave.fs-escape-write');
|
|
94
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
95
|
+
expectEscape('openwop.requirement.0173.pack-isolation.fs-write', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('node-pack-sandbox-no-env: the host environment does not leak', async () => {
|
|
99
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
100
|
+
const r = await invoke('misbehave.env-leak');
|
|
101
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
102
|
+
expectEscape('openwop.requirement.0173.pack-isolation.no-env', r, 'host-env-leak', 'node-pack-sandbox-no-env');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('node-pack-sandbox-network-gated: ungated network egress is refused', async () => {
|
|
106
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
107
|
+
const r = await invoke('misbehave.network-escape');
|
|
108
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
109
|
+
expectEscape('openwop.requirement.0173.pack-isolation.network', r, 'network-escape', 'node-pack-sandbox-network-gated');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('node-pack-sandbox-no-process: host process access is refused', async () => {
|
|
113
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
114
|
+
const r = await invoke('misbehave.process-escape');
|
|
115
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
116
|
+
expectEscape('openwop.requirement.0173.pack-isolation.no-process', r, 'host-process-escape', 'node-pack-sandbox-no-process');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('node-pack-sandbox-timeout: exceeding wallClockLimitMs fails with sandbox_timeout', async () => {
|
|
120
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
121
|
+
const r = await invoke('misbehave.timeout');
|
|
122
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
123
|
+
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.timeout', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-timeout: a wall-clock overrun MUST fail with sandbox_timeout')).toBe('sandbox_timeout');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('node-pack-sandbox-memory-cap: exceeding memoryLimitBytes fails with sandbox_memory_exceeded', async () => {
|
|
127
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
128
|
+
const r = await invoke('misbehave.memory-bomb');
|
|
129
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
130
|
+
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.memory-cap', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-memory-cap: a memory overrun MUST fail with sandbox_memory_exceeded')).toBe('sandbox_memory_exceeded');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('node-pack-sandbox-isolated-context: a pack cannot mutate state another invocation sees', async () => {
|
|
134
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
135
|
+
const first = await invoke('misbehave.cross-pack-mutate');
|
|
136
|
+
if (!first) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
137
|
+
const second = await invoke('misbehave.cross-pack-mutate');
|
|
138
|
+
if (!second) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
139
|
+
for (const r of [first, second]) {
|
|
140
|
+
expect(r.error, req('openwop.requirement.0173.pack-isolation.isolated-context', 'host-sample-test-seams.md §8', 'misbehave.cross-pack-mutate is not a failure mode — it MUST return a result')).toBeUndefined();
|
|
141
|
+
expect(
|
|
142
|
+
(r.result as { shared?: unknown } | undefined)?.shared,
|
|
143
|
+
req('openwop.requirement.0173.pack-isolation.isolated-context', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-isolated-context: result.shared MUST equal 1 on every invocation — a mutation MUST NOT leak into a fresh context'),
|
|
144
|
+
).toBe(1);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|