@openwop/openwop-conformance 2.45.2 → 2.45.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/lib/certification-bundle-verify.js +22 -1
  5. package/dist/lib/profiles.js +17 -2
  6. package/dist/lib/requirement-registry.js +15 -0
  7. package/dist/lib/scenario-disposition.js +50 -9
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/fixtures/conformance-fs-probe.json +25 -0
  10. package/fixtures/conformance-queue-consume.json +27 -0
  11. package/fixtures/conformance-queue-publish.json +29 -0
  12. package/fixtures/conformance-safefetch-probe.json +23 -0
  13. package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
  14. package/fixtures/conformance-storage-probe.json +27 -0
  15. package/fixtures/conformance-tool-scope-probe.json +27 -0
  16. package/fixtures/openwop-secrets-run-witness.json +29 -0
  17. package/fixtures.md +102 -1
  18. package/package.json +2 -2
  19. package/requirement-aliases.json +72 -71
  20. package/requirements.json +1011 -43
  21. package/scenario-majors.json +39 -3
  22. package/schemas/CORPUS-STAMP.json +22 -22
  23. package/src/cli.ts +1 -1
  24. package/src/lib/certification-bundle-verify.ts +23 -2
  25. package/src/lib/driver.ts +61 -2
  26. package/src/lib/memoryAttribution.ts +41 -6
  27. package/src/lib/polling.ts +2 -18
  28. package/src/lib/profiles.ts +26 -2
  29. package/src/lib/requirement-registry.ts +12 -0
  30. package/src/lib/run-secrets-witness.ts +240 -0
  31. package/src/lib/scenario-disposition.ts +45 -7
  32. package/src/lib/secret-scan.ts +141 -0
  33. package/src/lib/timeout-scale.ts +25 -0
  34. package/src/lib/triggerBridge.ts +69 -1
  35. package/src/scenarios/byok-roundtrip.test.ts +20 -4
  36. package/src/scenarios/runner-ledger.test.ts +6 -1
  37. package/src/scenarios/secrets-run-witness.test.ts +154 -0
  38. package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
  39. package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
  40. package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
  41. package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
  42. package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
  43. package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
  44. package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
  45. package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
  46. package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
  47. package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
  48. package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
  49. package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A refused inbound event dead-letters only its delivery — `trigger-bridge.md`
3
+ * §F.2, corrected 2026-09-30 (`docs/COMPATIBILITY-CORRECTIONS-LOG.md`).
4
+ *
5
+ * §F.2 used to send an event failing a `required` verification to
6
+ * `trigger.subscription.state.changed.reason: 'signature-invalid'`, a
7
+ * SUBSCRIPTION transition. Anyone able to post one badly signed event could
8
+ * then disable a working integration: every later delivery is refused as
9
+ * `subscription_not_active`. RFC 0230 §C.1 closed that for the uncredentialed
10
+ * Standard Webhooks ingest; the correction states it for every host.
11
+ *
12
+ * Driven through the delivery seam's OPTIONAL `scenario: "refused"` value
13
+ * (`host-sample-test-seams.md`, trigger-bridge delivery seam), then the
14
+ * `deliver` value on the same `subscriptionId`. Three observations, each of
15
+ * which a host implementing the old subscription-level transition fails:
16
+ *
17
+ * 1. the refused event is recorded as a dead-lettered
18
+ * `trigger.delivery.attempted` and starts no run;
19
+ * 2. no `trigger.subscription.state.changed` is emitted for the subscription,
20
+ * and it reads back `active` (seam response, and the normative
21
+ * `GET /v1/trigger-subscriptions/{id}` when the host serves it);
22
+ * 3. a verified event on the same subscription is then delivered and starts
23
+ * a run.
24
+ *
25
+ * Gated on the `openwop-trigger-bridge` profile and on
26
+ * `triggerBridge.ingestion.externalSources` listing `webhook`. The uncredentialed
27
+ * public path (RFC 0230, `inboundSigning`) is witnessed separately.
28
+ *
29
+ * @see spec/v1/trigger-bridge.md §F.2
30
+ * @see spec/v2/core/webhooks.md §Inbound triggers
31
+ * @see RFCS/0230-inbound-webhook-ingest-contract.md §C.1 (risk R3)
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { driver } from '../lib/driver.js';
36
+ import { seamAbsent, softSkip } from '../lib/soft-skip.js';
37
+ import { behaviorGate } from '../lib/behavior-gate.js';
38
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
39
+ import { isTriggerBridgeProfileAdvertised } from '../lib/triggerBridge.js';
40
+ import { queryTestEvents, requireEvents, isEventLogSeamAvailable } from '../lib/event-log-query.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+
43
+ const SEAM = '/v1/host/sample/trigger-bridge/deliver';
44
+ const ID = 'openwop.requirement.trigger-bridge.refused-event-keeps-subscription';
45
+ const SPEC = 'trigger-bridge.md §F.2 (corrected 2026-09-30)';
46
+
47
+ interface SeamResult {
48
+ subscriptionId?: unknown;
49
+ outcome?: unknown;
50
+ subscriptionState?: unknown;
51
+ runId?: unknown;
52
+ }
53
+
54
+ describe('trigger-refused-event-keeps-subscription (trigger-bridge.md §F.2)', () => {
55
+ it('a refused event dead-letters only its delivery; the subscription stays active and a verified event then delivers', async () => {
56
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
57
+ const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: unknown } }>('triggerBridge');
58
+ const sources = Array.isArray(tb?.ingestion?.externalSources) ? (tb!.ingestion!.externalSources as unknown[]) : [];
59
+ if (!sources.includes('webhook')) {
60
+ return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources does not list webhook, so the host verifies no inbound webhook event');
61
+ }
62
+ if (!(await isEventLogSeamAvailable())) return seamAbsent('host ingests external webhooks but the event-log seam is absent');
63
+
64
+ // ---- 1. The refused event -------------------------------------------
65
+ const refusedRes = await driver.post(SEAM, { scenario: 'refused', source: 'webhook' });
66
+ if (refusedRes.status === 404 || refusedRes.status === 405) {
67
+ return seamAbsent('host ingests external webhooks but the trigger-bridge delivery seam is unwired');
68
+ }
69
+ if (refusedRes.status === 400) {
70
+ // Grace window (suite 2.45.3): the `refused` seam value is new in this cycle, so a
71
+ // host whose existing delivery seam predates it records `inapplicable` with this
72
+ // reason rather than `blocked` (which would deny its certification for a seam
73
+ // extension it has not yet shipped). The disposition for an advertised facet whose
74
+ // fixture or seam value is missing is pending a suite-wide policy decision.
75
+ return softSkip('inapplicable', 'the trigger-bridge delivery seam predates scenario "refused" (answered 400); grace window for seams extended in 2.45.3');
76
+ }
77
+ expect(refusedRes.status, req(ID, 'host-sample-test-seams.md (trigger-bridge delivery seam)', 'scenario "refused" MUST answer 200')).toBe(200);
78
+ const refused = (refusedRes.json ?? {}) as SeamResult;
79
+ expect(
80
+ typeof refused.subscriptionId === 'string' && refused.subscriptionId.length > 0,
81
+ req(ID, 'host-sample-test-seams.md (trigger-bridge delivery seam)', 'scenario "refused" MUST return the subscriptionId it drove'),
82
+ ).toBe(true);
83
+ const subscriptionId = refused.subscriptionId as string;
84
+ expect(
85
+ refused.outcome,
86
+ req(ID, SPEC, 'an event failing a required verification MUST NOT start a run; its delivery is dead-lettered'),
87
+ ).toBe('dead-lettered');
88
+
89
+ const logKey = typeof refused.runId === 'string' && refused.runId.length > 0 ? refused.runId : '__refused__';
90
+ const attempts = requireEvents(await queryTestEvents(logKey, { type: 'trigger.delivery.attempted' }), 'trigger.delivery.attempted (refused)')
91
+ .filter((e) => e.payload.subscriptionId === subscriptionId);
92
+ expect(
93
+ attempts.some((e) => e.payload.outcome === 'dead-lettered'),
94
+ req(ID, SPEC, 'the refused delivery MUST be recorded as trigger.delivery.attempted { outcome: "dead-lettered" }'),
95
+ ).toBe(true);
96
+ expect(
97
+ attempts.every((e) => e.payload.outcome !== 'delivered'),
98
+ req(ID, SPEC, 'an event failing a required verification MUST NOT be delivered'),
99
+ ).toBe(true);
100
+ const started = requireEvents(await queryTestEvents(logKey, { type: 'run.started' }), 'run.started (refused)');
101
+ expect(started.length, req(ID, SPEC, 'an event failing a required verification MUST NOT start a run')).toBe(0);
102
+
103
+ // ---- 2. The subscription is untouched --------------------------------
104
+ const transitions = requireEvents(await queryTestEvents(logKey, { type: 'trigger.subscription.state.changed' }), 'trigger.subscription.state.changed (refused)')
105
+ .filter((e) => e.payload.subscriptionId === subscriptionId);
106
+ expect(
107
+ transitions.map((e) => `${String(e.payload.fromState)}->${String(e.payload.toState)} (${String(e.payload.reason)})`),
108
+ req(ID, SPEC, 'a refused event MUST NOT emit trigger.subscription.state.changed — one bad post must not disable the integration'),
109
+ ).toEqual([]);
110
+ expect(
111
+ refused.subscriptionState,
112
+ req(ID, SPEC, 'a refused event MUST NOT change the subscription\'s state: it stays active'),
113
+ ).toBe('active');
114
+ const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(subscriptionId)}`);
115
+ if (read.status === 200) {
116
+ const body = read.json as { state?: unknown; subscription?: { state?: unknown } } | undefined;
117
+ expect(
118
+ body?.subscription?.state ?? body?.state,
119
+ req(ID, SPEC, 'the normative subscription read MUST still show state active after a refused event'),
120
+ ).toBe('active');
121
+ }
122
+
123
+ // ---- 3. A verified event on the same subscription is delivered -------
124
+ const nextRes = await driver.post(SEAM, { scenario: 'deliver', source: 'webhook', subscriptionId });
125
+ expect(nextRes.status, req(ID, 'host-sample-test-seams.md (trigger-bridge delivery seam)', 'scenario "deliver" with the refused subscriptionId MUST answer 200')).toBe(200);
126
+ const next = (nextRes.json ?? {}) as SeamResult;
127
+ expect(
128
+ next.subscriptionId,
129
+ req(ID, 'host-sample-test-seams.md (trigger-bridge delivery seam)', 'scenario "deliver" MUST deliver on the subscriptionId it was given'),
130
+ ).toBe(subscriptionId);
131
+ expect(
132
+ typeof next.runId === 'string' && next.runId.length > 0,
133
+ req(ID, SPEC, 'after a refused event, a verified event on the same subscription MUST start a run'),
134
+ ).toBe(true);
135
+ const delivered = requireEvents(await queryTestEvents(next.runId as string, { type: 'trigger.delivery.attempted' }), 'trigger.delivery.attempted (deliver after refused)');
136
+ expect(
137
+ delivered.some((e) => e.payload.subscriptionId === subscriptionId && e.payload.outcome === 'delivered'),
138
+ req(ID, SPEC, 'after a refused event, the next verified delivery on the subscription MUST be recorded delivered'),
139
+ ).toBe(true);
140
+ });
141
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * v2 — `mode: eval` on a host that does not advertise `agents.evalSuite` is
3
+ * refused `422 capability_not_provided` (`spec/v2/core/runs.md` §Refusals).
4
+ *
5
+ * The v1 eval scenarios (`agent-eval-run`, `agent-eval-suite-shape`) run at
6
+ * major 1 only and return early when the capability is absent, so nothing
7
+ * measured this refusal at major 2. The leg is unaided: the suite reads
8
+ * discovery and posts one create.
9
+ *
10
+ * The body is a complete eval create (`mode`, `evalSuiteRef`, `agentId`), so a
11
+ * `400 validation_error` is not an honest answer to it: the only thing wrong
12
+ * with the request is that the host does not run eval suites.
13
+ *
14
+ * A host that advertises `agents.evalSuite` records the leg `inapplicable`.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
+ import { v2Discovery } from '../lib/v2.js';
20
+ import { softSkip } from '../lib/soft-skip.js';
21
+ import { req } from '../lib/requirement-ids.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+
24
+ const ID = 'openwop.requirement.runs.eval-mode-unadvertised-refused';
25
+ const DOC = 'spec/v2/core/runs.md §Refusals';
26
+
27
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
28
+ try { return await fn(); } catch { return null; }
29
+ }
30
+
31
+ describe('v2 eval mode is refused where agents.evalSuite is not advertised (runs.md §Refusals)', () => {
32
+ it('POST /runs {mode: "eval"} answers 422 capability_not_provided and creates no run', async () => {
33
+ let doc: Record<string, unknown> | null;
34
+ try { doc = await v2Discovery(); } catch { doc = null; }
35
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
36
+ const agents = doc['agents'];
37
+ const evalSuite = agents !== null && typeof agents === 'object' && !Array.isArray(agents) ? (agents as Record<string, unknown>)['evalSuite'] : undefined;
38
+ if (evalSuite !== undefined && evalSuite !== null) return softSkip('inapplicable', 'the host advertises agents.evalSuite — mode: eval is served');
39
+
40
+ const res = await http(() => driver.post('/runs', {
41
+ mode: 'eval',
42
+ evalSuiteRef: 'https://conformance.openwop.dev/eval-suites/unadvertised-probe',
43
+ agentId: 'conformance-eval-unadvertised-probe',
44
+ }));
45
+ if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
46
+ if (res.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
47
+
48
+ // A host that accepted the create started something. Stop it before asserting, so a failing row leaves no run behind.
49
+ const runId = (res.json as { runId?: unknown } | undefined)?.runId;
50
+ if (res.status === 201 && typeof runId === 'string') await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
51
+
52
+ expect(res.status, req(ID, DOC, `a host that does not advertise agents.evalSuite MUST reject mode: eval with 422 (got ${res.status} ${readErrorCode(res.json) ?? ''})`.trim())).toBe(422);
53
+ expect(readErrorCode(res.json), req(ID, DOC, 'the 422 for an unadvertised eval mode MUST carry the registered code capability_not_provided')).toBe('capability_not_provided');
54
+ });
55
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `spec/v2/core/storage.md` §`fs` — pack file access never escapes the
3
+ * sandbox root (target major 2; gated on `fs` + the `conformance-fs-probe`
4
+ * fixture).
5
+ *
6
+ * The rules: "Every `path` MUST be normalized and resolved relative to
7
+ * `fs.sandboxRoot`. A path that escapes the root, whether absolute, through
8
+ * `..` segments or through a symlink, MUST be refused. The host MUST NOT follow
9
+ * such a link partially." And, from §Shared rules (RFC 0228's decision): "A
10
+ * sandbox escape is `forbidden` with `details.reason: path-outside-sandbox`."
11
+ * `errors.md` §Host-service refusals adds that a generic code carries
12
+ * `details.service`, the family key. Until this file the rule had a witness
13
+ * only at major 1, through the v1 seam `POST /v1/host/sample/fs/read`.
14
+ *
15
+ * No seam: each leg runs the `conformance-fs-probe` fixture, whose node calls
16
+ * the host's own `ctx.fs` with the path unchanged and fails with the
17
+ * rejection's code and details (conformance/fixtures.md §"The storage probe
18
+ * fixtures").
19
+ *
20
+ * inside-root write then read `conformance/probe-<nonce>.txt` — the
21
+ * content round-trips (the positive control: a host whose
22
+ * probe refuses everything cannot pass the escape legs by
23
+ * accident, because this leg fails first);
24
+ * absolute-escape read `/etc/hosts` (outside any root other than `/` or
25
+ * `/etc`) — `forbidden`, `path-outside-sandbox`;
26
+ * dotdot-escape read `conformance/../../../../../../../../etc/hosts` —
27
+ * the same;
28
+ * symlink-escape read `conformance/escape-link`, which the operator points
29
+ * outside the root — the same. A host that normalises `..`
30
+ * but follows links reads the file and fails here.
31
+ *
32
+ * Dispositions: `fs` absent from the v2 root ⇒ `inapplicable`. `fs` advertised
33
+ * without the fixture, or a run that cannot be created ⇒ `blocked`. A root of
34
+ * `/` or `/etc` (so `/etc/hosts` is inside it) ⇒ that escape leg is
35
+ * `inapplicable`. The symlink leg answering `not_found` means the operator link
36
+ * is missing ⇒ `blocked`, never a pass.
37
+ *
38
+ * Sabotage (a patched local copy of the v2 reference host): skip the prefix
39
+ * check for absolute paths, resolve without normalising `..`, or normalise
40
+ * `..` but follow symlinks — each turns exactly its own leg red.
41
+ *
42
+ * @see spec/v2/core/storage.md §fs
43
+ * @see RFCS/0228-v1-host-service-error-codes.md §Decisions 1
44
+ */
45
+
46
+ import { describe, it, expect } from 'vitest';
47
+ import { randomUUID } from 'node:crypto';
48
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
49
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
50
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
51
+ import { readErrorCode } from '../lib/error-envelope.js';
52
+ import { softSkip } from '../lib/soft-skip.js';
53
+ import { req } from '../lib/requirement-ids.js';
54
+
55
+ const FIXTURE = 'conformance-fs-probe';
56
+ const NODE_ID = 'fs-probe';
57
+ const DOC = 'spec/v2/core/storage.md §fs';
58
+ const DOC_CODE = 'spec/v2/core/storage.md §Shared rules';
59
+ const ID_INSIDE = 'openwop.requirement.storage.fs-inside-root-read';
60
+ const ID_ABSOLUTE = 'openwop.requirement.storage.fs-absolute-escape-refused';
61
+ const ID_DOTDOT = 'openwop.requirement.storage.fs-dotdot-escape-refused';
62
+ const ID_SYMLINK = 'openwop.requirement.storage.fs-symlink-escape-refused';
63
+ const OUTSIDE = '/etc/hosts';
64
+ const DOTDOT = 'conformance/../../../../../../../../etc/hosts';
65
+ const SYMLINK = 'conformance/escape-link';
66
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
67
+
68
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
69
+ const enc = (id: string): string => encodeURIComponent(id);
70
+
71
+ interface Outcome {
72
+ readonly status: string;
73
+ readonly result: Record<string, unknown> | null;
74
+ readonly error: { code: string | null; details: Record<string, unknown> } | null;
75
+ }
76
+
77
+ async function runProbe(inputs: Record<string, unknown>): Promise<Outcome | { reason: string }> {
78
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs }));
79
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
80
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
81
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${FIXTURE}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the fixture run was refused`.trim() };
82
+ const t0 = Date.now(); let status = '';
83
+ while (Date.now() - t0 < 30_000) {
84
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
85
+ status = String((snap?.json as { status?: unknown } | null)?.status ?? '');
86
+ if (TERMINAL.has(status)) break;
87
+ await new Promise((r) => setTimeout(r, 150));
88
+ }
89
+ if (!TERMINAL.has(status)) return { reason: `the ${FIXTURE} run did not reach a terminal status within 30 s (last: ${status || 'unreadable'})` };
90
+ const ev = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
91
+ const events = (ev?.json as { events?: unknown } | null)?.events;
92
+ if (ev?.status !== 200 || !Array.isArray(events)) return { reason: `GET /runs/{runId}/events/poll answered ${ev?.status ?? 'nothing'}` };
93
+ const node = (type: string): Record<string, unknown> | undefined => (events as Array<{ type?: unknown; payload?: Record<string, unknown> }>).find((e) => e.type === type && e.payload?.['nodeId'] === NODE_ID)?.payload;
94
+ const result = ((node('node.completed')?.['outputs'] ?? {}) as Record<string, unknown>)['result'];
95
+ const err = (node('node.failed')?.['error'] ?? null) as { code?: unknown; details?: unknown } | null;
96
+ return {
97
+ status,
98
+ result: result && typeof result === 'object' && !Array.isArray(result) ? (result as Record<string, unknown>) : null,
99
+ error: err === null ? null : { code: typeof err.code === 'string' ? err.code : null, details: err.details && typeof err.details === 'object' ? (err.details as Record<string, unknown>) : {} },
100
+ };
101
+ }
102
+
103
+ /** The gate, and the advertised `sandboxRoot`, or the recorded reason the leg cannot run. */
104
+ async function gate(): Promise<{ root: string } | { skip: ['inapplicable' | 'blocked', string] }> {
105
+ if (!(await v2Discovery().catch(() => null))) return { skip: ['blocked', 'v2 discovery unreachable'] };
106
+ const fs = await familyAdvertised('fs');
107
+ if (!fs) return { skip: ['inapplicable', 'fs is not advertised in the v2 discovery root — the host exposes no ctx.fs to pack code'] };
108
+ if (!isFixtureAdvertised(FIXTURE)) return { skip: ['blocked', `the host advertises fs but not the ${FIXTURE} fixture — the sandbox claim is made and cannot be observed without it`] };
109
+ return { root: typeof fs['sandboxRoot'] === 'string' ? fs['sandboxRoot'] : '' };
110
+ }
111
+
112
+ /** `/etc/hosts` escapes every root except `/` and `/etc` (and their spellings). */
113
+ const rootContainsOutside = (root: string): boolean => { const r = root.replace(/\/+$/, ''); return r === '' || r === '/etc'; };
114
+
115
+ describe('v2 fs sandbox (storage.md §fs)', () => {
116
+ it('a path inside the sandbox root is written and read back', async () => {
117
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
118
+ const nonce = randomUUID();
119
+ const path = `conformance/probe-${nonce}.txt`;
120
+ const w = await runProbe({ op: 'write', path, content: nonce });
121
+ if ('reason' in w) return softSkip('blocked', `write: ${w.reason}`);
122
+ const r = await runProbe({ op: 'read', path });
123
+ if ('reason' in r) return softSkip('blocked', `read: ${r.reason}`);
124
+ expect(w.status, req(ID_INSIDE, DOC, `a write inside the root MUST succeed (got ${w.status} ${w.error?.code ?? ''})`)).toBe('completed');
125
+ expect(r.status, req(ID_INSIDE, DOC, `a read inside the root MUST succeed (got ${r.status} ${r.error?.code ?? ''})`)).toBe('completed');
126
+ expect(r.result?.['content'], req(ID_INSIDE, 'conformance/fixtures.md §The storage probe fixtures', 'the read MUST return the bytes written')).toBe(nonce);
127
+ }, 90_000);
128
+
129
+ it('an absolute path outside the root is refused forbidden, path-outside-sandbox', async () => {
130
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
131
+ if (rootContainsOutside(g.root)) return softSkip('inapplicable', `sandboxRoot ${JSON.stringify(g.root)} contains ${OUTSIDE}, so that absolute path is not an escape`);
132
+ const o = await runProbe({ op: 'read', path: OUTSIDE });
133
+ if ('reason' in o) return softSkip('blocked', o.reason);
134
+ expect(o.status, req(ID_ABSOLUTE, DOC, `an absolute path outside the root MUST be refused — the run ${o.status === 'completed' ? `read it (${String(o.result?.['content'] ?? '').length} chars)` : o.status}`)).toBe('failed');
135
+ expect(o.error?.code, req(ID_ABSOLUTE, DOC_CODE, 'a sandbox escape is `forbidden`')).toBe('forbidden');
136
+ expect(o.error?.details['reason'], req(ID_ABSOLUTE, DOC_CODE, 'a sandbox escape carries details.reason: path-outside-sandbox')).toBe('path-outside-sandbox');
137
+ expect(o.error?.details['service'], req(ID_ABSOLUTE, 'spec/v2/core/errors.md §Host-service refusals', 'a generic code MUST carry details.service, the family key')).toBe('fs');
138
+ }, 60_000);
139
+
140
+ it('a dot-dot path that climbs out of the root is refused forbidden, path-outside-sandbox', async () => {
141
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
142
+ if (rootContainsOutside(g.root)) return softSkip('inapplicable', `sandboxRoot ${JSON.stringify(g.root)} contains ${OUTSIDE}, so no dot-dot path reaches outside it`);
143
+ const o = await runProbe({ op: 'read', path: DOTDOT });
144
+ if ('reason' in o) return softSkip('blocked', o.reason);
145
+ expect(o.status, req(ID_DOTDOT, DOC, `a path escaping through .. segments MUST be refused — the run ${o.status === 'completed' ? 'read the file' : o.status}`)).toBe('failed');
146
+ expect(o.error?.code, req(ID_DOTDOT, DOC_CODE, 'a sandbox escape is `forbidden`')).toBe('forbidden');
147
+ expect(o.error?.details['reason'], req(ID_DOTDOT, DOC_CODE, 'a sandbox escape carries details.reason: path-outside-sandbox')).toBe('path-outside-sandbox');
148
+ expect(o.error?.details['service'], req(ID_DOTDOT, 'spec/v2/core/errors.md §Host-service refusals', 'a generic code MUST carry details.service, the family key')).toBe('fs');
149
+ }, 60_000);
150
+
151
+ it('a symlink inside the root that points outside it is refused forbidden, path-outside-sandbox', async () => {
152
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
153
+ const o = await runProbe({ op: 'read', path: SYMLINK });
154
+ if ('reason' in o) return softSkip('blocked', o.reason);
155
+ if (o.status === 'failed' && o.error?.code === 'not_found') return softSkip('blocked', `${SYMLINK} answered not_found — the operator symlink the fixture contract requires is missing, so the leg observed nothing`);
156
+ expect(o.status, req(ID_SYMLINK, DOC, `a path escaping through a symlink MUST be refused, never followed — the run ${o.status === 'completed' ? 'read the link target' : o.status}`)).toBe('failed');
157
+ expect(o.error?.code, req(ID_SYMLINK, DOC_CODE, 'a sandbox escape is `forbidden`')).toBe('forbidden');
158
+ expect(o.error?.details['reason'], req(ID_SYMLINK, DOC_CODE, 'a sandbox escape carries details.reason: path-outside-sandbox')).toBe('path-outside-sandbox');
159
+ expect(o.error?.details['service'], req(ID_SYMLINK, 'spec/v2/core/errors.md §Host-service refusals', 'a generic code MUST carry details.service, the family key')).toBe('fs');
160
+ }, 60_000);
161
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `v2-memory-cross-tenant-isolation` (target major 2; gated on the `memory`
3
+ * family and the `conformance-agent-memory-cross-tenant` fixture).
4
+ *
5
+ * `spec/v2/core/host-services.md` §`memory`, **Tenant isolation**: "A ref MUST
6
+ * resolve to one tenant's entries, whatever the caller's permissions." No
7
+ * protocol path serves memory — pack code reads it as `ctx.memory` — so the
8
+ * observation surface is a run of the EXISTING fixture
9
+ * `conformance-agent-memory-cross-tenant` (conformance/fixtures.md), whose node
10
+ * writes one entry at the fixed `memoryRef` `conformance/agent-memory-cti` and
11
+ * lands `ownerEntryId`, then lists that ref into `ownerProbe`. No seam, no new
12
+ * fixture.
13
+ *
14
+ * The witness is two tenants running the SAME fixture, so both name the SAME
15
+ * ref string. Tenant A (OPENWOP_API_KEY) runs it first; tenant B
16
+ * (OPENWOP_TEST_TENANT_B_API_KEY) runs it second, and B's `ownerProbe` — a list
17
+ * of that ref under B's identity, issued after A's write — MUST hold none of
18
+ * A's entries. A host that resolves the ref without its tenant returns A's
19
+ * entry to B here. The major-1 scenario (`agentMemoryCrossTenantIsolation`)
20
+ * runs under one tenant and probes a ref nobody wrote, so it could not see that
21
+ * defect; until this file no major-2 scenario cited §`memory` at all
22
+ * (docs/V2-WITNESS-COVERAGE.md risk #2).
23
+ *
24
+ * Not witnessed here: the malformed-ref half (traversal, NUL, oversize ⇒ `[]` /
25
+ * `null`). The fixture's refs are fixed strings, and a new ref-taking fixture
26
+ * would be a new host obligation with no implementer to prove it against.
27
+ *
28
+ * Dispositions: family not advertised, or advertised without the fixture ⇒
29
+ * `inapplicable` (the host has not claimed the fixture). Fixture advertised but
30
+ * the second-tenant key is absent / identical, a run cannot be created, or the
31
+ * fixture's own positive control (a non-empty `ownerProbe` holding
32
+ * `ownerEntryId`) is absent ⇒ `blocked`: the host took the obligation on and
33
+ * the suite could not measure it.
34
+ */
35
+ import { describe, it, expect } from 'vitest';
36
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
37
+ import { loadEnv } from '../lib/env.js';
38
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
39
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
40
+ import { scaledTimeoutMs } from '../lib/polling.js';
41
+ import { softSkip } from '../lib/soft-skip.js';
42
+ import { req } from '../lib/requirement-ids.js';
43
+
44
+ const DOC = 'spec/v2/core/host-services.md §memory';
45
+ const FIXTURE = 'conformance-agent-memory-cross-tenant';
46
+ const ID = 'openwop.requirement.0004.memory-ref-one-tenant';
47
+
48
+ interface Probe { runId: string; status: string; entryId: string | null; ownerIds: string[]; ownerProbe: unknown; crossTenantProbe: unknown }
49
+
50
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
51
+ try { return await fn(); } catch { return null; }
52
+ }
53
+
54
+ function idsOf(list: unknown): string[] {
55
+ return Array.isArray(list) ? list.map((e) => (e && typeof e === 'object' ? (e as { id?: unknown }).id : undefined)).filter((x): x is string => typeof x === 'string') : [];
56
+ }
57
+
58
+ /** One settled run of the fixture as the given caller, with the variables the fixture lands. */
59
+ async function runFixture(as: Record<string, unknown>, who: string): Promise<Probe | { reason: string }> {
60
+ const res = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: {} }, as));
61
+ const runId = (res?.json as { runId?: unknown } | undefined)?.runId;
62
+ if (res === null || res.status !== 201 || typeof runId !== 'string') return { reason: `${who}: POST /runs {${FIXTURE}} answered ${res?.status ?? 'nothing'}` };
63
+ const deadline = Date.now() + scaledTimeoutMs(15_000);
64
+ while (Date.now() < deadline) {
65
+ const s = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, as));
66
+ const snap = s?.json as { status?: unknown; variables?: Record<string, unknown> } | undefined;
67
+ const status = String(snap?.status ?? '');
68
+ if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(status)) {
69
+ const v = snap?.variables ?? {};
70
+ return { runId, status, entryId: typeof v['ownerEntryId'] === 'string' ? v['ownerEntryId'] : null, ownerIds: idsOf(v['ownerProbe']), ownerProbe: v['ownerProbe'], crossTenantProbe: v['crossTenantProbe'] };
71
+ }
72
+ await new Promise((r) => setTimeout(r, 250));
73
+ }
74
+ return { reason: `${who}: the ${FIXTURE} run ${runId} did not settle within 15 s` };
75
+ }
76
+
77
+ /**
78
+ * The fixture's own positive control: its owner list holds the entry it wrote.
79
+ * `failed` is allowed — the catalog lets a host fail the run at the fixture's
80
+ * third step (the probe of a foreign ref), after the owner write and list.
81
+ */
82
+ function controlled(p: Probe): boolean {
83
+ return ['completed', 'failed'].includes(p.status) && p.entryId !== null && p.ownerIds.includes(p.entryId);
84
+ }
85
+
86
+ describe('v2 memory: a ref resolves to one tenant\'s entries (host-services.md §memory)', () => {
87
+ it('a second tenant listing the same memoryRef sees none of the first tenant\'s entries', async () => {
88
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
89
+ if (!(await gateFamily('memory'))) return softSkip('inapplicable', 'memory family not advertised (gate recorded under openwop.family.memory)');
90
+ if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `fixture ${FIXTURE} is not advertised — memory has no protocol path, and the host does not claim the fixture that observes it`);
91
+ const b = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
92
+ if (!b) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant leg cannot run');
93
+ if (b === loadEnv().apiKey) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the leg needs a second tenant');
94
+ const asB = { authenticated: false, headers: { Authorization: `Bearer ${b}` } };
95
+
96
+ const a = await runFixture({}, 'tenant A');
97
+ if ('reason' in a) return softSkip('blocked', a.reason);
98
+ if (!controlled(a)) return softSkip('blocked', `tenant A's run ${a.runId} ended ${a.status} without the fixture's positive control (ownerProbe holding ownerEntryId ${String(a.entryId)}) — the adapter was not observably exercised`);
99
+ const bRun = await runFixture(asB, 'tenant B');
100
+ if ('reason' in bRun) return softSkip('blocked', bRun.reason);
101
+ if (bRun.runId.includes('/') && a.runId.includes('/') && bRun.runId.split('/')[0] === a.runId.split('/')[0]) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY (the run ids share a tenant segment) — the leg needs a second tenant');
102
+ if (!controlled(bRun)) return softSkip('blocked', `tenant B's run ${bRun.runId} ended ${bRun.status} without the fixture's positive control — the list B's run issued was not observed`);
103
+ if (bRun.entryId === a.entryId) return softSkip('blocked', `both tenants' writes report entry id ${String(a.entryId)} — ids that repeat across tenants cannot tell A's entry from B's`);
104
+
105
+ const leaked = a.ownerIds.filter((id) => bRun.ownerIds.includes(id));
106
+ expect(leaked, req(ID, DOC, `a ref MUST resolve to one tenant's entries: tenant B's list of conformance/agent-memory-cti (run ${bRun.runId}) returned tenant A's entries`)).toEqual([]);
107
+ expect(bRun.ownerIds, req(ID, DOC, `tenant B's list MUST NOT hold the entry tenant A just wrote (${String(a.entryId)})`)).not.toContain(a.entryId);
108
+ for (const p of [a, bRun].filter((r) => r.status === 'completed')) {
109
+ const x = p.crossTenantProbe;
110
+ expect(x === null || (Array.isArray(x) && x.length === 0), req(ID, DOC, `the fixture's probe of a ref another tenant owns MUST be [] or null (run ${p.runId})`)).toBe(true);
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `spec/v2/core/host-services.md` §`queueBus` — "A tenant's consumer MUST NOT
3
+ * receive another tenant's messages, even on the same topic." (target major 2;
4
+ * gated on the `queueBus` family, the queue-probe fixture pair and a second
5
+ * tenant's credential).
6
+ *
7
+ * Before this file the rule was witnessed at major 1 only
8
+ * (`queue-cross-tenant-isolation`), through the v1 seam
9
+ * `POST /v1/host/sample/test/surface`, which names the tenant in the REQUEST
10
+ * BODY — so it never tested the thing that matters, that the tenant comes from
11
+ * the credential. Topics are named by pack authors, so two tenants running the
12
+ * same pack share topic names by default; a host that keys the physical queue
13
+ * by topic alone hands one tenant's messages to the other.
14
+ *
15
+ * No seam (`conformance.md` forbids growing the seam count). The leg drives
16
+ * `ctx.queueBus` through the normal run surface with two fixtures
17
+ * (`conformance/fixtures.md` §`conformance-queue-publish` /
18
+ * §`conformance-queue-consume`): tenant A (`OPENWOP_API_KEY`) publishes a fresh
19
+ * nonce on a fresh topic T; tenant B (`OPENWOP_TEST_TENANT_B_API_KEY`) consumes
20
+ * T and MUST NOT receive it; then A consumes T and MUST receive it — the
21
+ * positive control, without which "B got nothing" would also be what a host
22
+ * whose queue does nothing at all shows.
23
+ *
24
+ * Dispositions: `queueBus` not advertised, or either probe fixture not
25
+ * advertised ⇒ `inapplicable` (read with `familyAdvertised`, not `gateFamily`:
26
+ * no host advertises `queueBus` at v2 today, and a strict-mode family gate would
27
+ * demand a `family.queueBus` opt-out from every one of them). The family and fixtures advertised but no second
28
+ * tenant key, or the key binds the primary tenant ⇒ `blocked` (the only
29
+ * observation that convicts a leaking host did not run). A probe run that does
30
+ * not complete, or a consume that leaves `consumed` unset, fails: the fixture
31
+ * contract is the host's claim once it advertises the fixture.
32
+ *
33
+ * @see spec/v2/core/host-services.md §`queueBus`
34
+ * @see SECURITY/invariants.yaml `queue-cross-tenant-isolation`
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { randomBytes } from 'node:crypto';
39
+ import { driver, type OpenWOPResponse, type OpenWOPRequestInit } from '../lib/driver.js';
40
+ import { familyAdvertised, v2Discovery } from '../lib/v2.js';
41
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
42
+ import { readErrorCode } from '../lib/error-envelope.js';
43
+ import { softSkip } from '../lib/soft-skip.js';
44
+ import { req } from '../lib/requirement-ids.js';
45
+
46
+ const DOC = 'spec/v2/core/host-services.md §queueBus';
47
+ const ID = 'openwop.requirement.queueBus.cross-tenant-isolation';
48
+ const PUBLISH = 'conformance-queue-publish';
49
+ const CONSUME = 'conformance-queue-consume';
50
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
51
+
52
+ const enc = (id: string): string => encodeURIComponent(id);
53
+ const tenantOf = (runId: string): string => runId.slice(0, Math.max(0, runId.indexOf('/')));
54
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
55
+
56
+ /** The `consumed` array the consume node output, wherever the host surfaces it (node.completed, run.completed, the snapshot). */
57
+ function consumedOf(docs: readonly unknown[]): string[] | null {
58
+ let hit: string[] | null = null;
59
+ const walk = (v: unknown): void => {
60
+ if (hit !== null || v === null || typeof v !== 'object') return;
61
+ if (Array.isArray(v)) { v.forEach(walk); return; }
62
+ const c = (v as Record<string, unknown>)['consumed'];
63
+ if (Array.isArray(c)) { hit = c.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))); return; }
64
+ Object.values(v as Record<string, unknown>).forEach(walk);
65
+ };
66
+ docs.forEach(walk);
67
+ return hit;
68
+ }
69
+
70
+ interface Ran { readonly runId: string; readonly status: string; readonly docs: unknown[] }
71
+
72
+ async function run(workflowId: string, inputs: Record<string, unknown>, as: OpenWOPRequestInit, who: string): Promise<Ran | { reason: string }> {
73
+ const created = await http(() => driver.post('/runs', { workflowId, inputs }, as));
74
+ if (created === null) return { reason: `${who}: POST /runs unreachable (fetch failed)` };
75
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
76
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `${who}: POST /runs {workflowId: ${workflowId}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the fixture is advertised but did not start`.trim() };
77
+ const deadline = Date.now() + 20_000;
78
+ let snap: OpenWOPResponse | null = null;
79
+ let status = '';
80
+ while (Date.now() < deadline) {
81
+ snap = await http(() => driver.get(`/runs/${enc(runId)}`, as));
82
+ status = snap?.status === 200 ? String((snap.json as { status?: unknown } | undefined)?.status ?? '') : status;
83
+ if (TERMINAL.has(status)) break;
84
+ await new Promise((r) => setTimeout(r, 200));
85
+ }
86
+ if (!TERMINAL.has(status)) return { reason: `${who}: the ${workflowId} run ${runId} did not settle within 20 s (last status: ${status || 'unreadable'})` };
87
+ const poll = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`, as));
88
+ return { runId, status, docs: [snap?.json, poll?.status === 200 ? poll.json : null] };
89
+ }
90
+
91
+ describe('v2 queue-cross-tenant-isolation (host-services.md §queueBus)', () => {
92
+ it('a message tenant A publishes on topic T is never consumed by tenant B on T, and A\'s own consumer receives it', async () => {
93
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 under OpenWOP-Version: 2.0');
94
+ if (!(await familyAdvertised('queueBus'))) return softSkip('inapplicable', 'queueBus family not advertised — no obligation');
95
+ for (const f of [PUBLISH, CONSUME]) if (!isFixtureAdvertised(f)) return softSkip('inapplicable', `fixture ${f} is not advertised — the host does not claim the queue-probe fixture pair`);
96
+ const keyB = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
97
+ if (!keyB) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY is not set — the second-tenant consume, the only observation a leaking host fails, cannot run');
98
+ const asA: OpenWOPRequestInit = {};
99
+ const asB: OpenWOPRequestInit = { authenticated: false, headers: { Authorization: `Bearer ${keyB}` } };
100
+
101
+ const topic = `openwop.conformance.xtenant.${randomBytes(6).toString('hex')}`;
102
+ const nonce = `openwop-conformance-queue-${randomBytes(12).toString('hex')}`;
103
+
104
+ const pub = await run(PUBLISH, { topic, message: nonce }, asA, 'tenant A publish'); if ('reason' in pub) return softSkip('blocked', pub.reason);
105
+ expect(pub.status, req(ID, `conformance/fixtures.md §${PUBLISH}`, `tenant A's publish run MUST complete (got ${pub.status})`)).toBe('completed');
106
+
107
+ const b = await run(CONSUME, { topic }, asB, 'tenant B consume'); if ('reason' in b) return softSkip('blocked', b.reason);
108
+ if (tenantOf(b.runId) === tenantOf(pub.runId)) return softSkip('blocked', `OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY (${tenantOf(pub.runId)}) — the leg needs a second tenant`);
109
+ expect(b.status, req(ID, `conformance/fixtures.md §${CONSUME}`, `tenant B's consume run MUST complete (got ${b.status})`)).toBe('completed');
110
+ const gotB = consumedOf(b.docs);
111
+ expect(Array.isArray(gotB), req(ID, `conformance/fixtures.md §${CONSUME}`, 'the consume node MUST output `consumed` as an array ([] when nothing arrived), never leave it unset — an unset output would pass the isolation check vacuously')).toBe(true);
112
+ expect(gotB ?? [], req(ID, DOC, `a tenant's consumer MUST NOT receive another tenant's messages, even on the same topic — tenant B (${tenantOf(b.runId)}) consumed tenant A's (${tenantOf(pub.runId)}) message on ${topic}`)).not.toContain(nonce);
113
+
114
+ const a = await run(CONSUME, { topic }, asA, 'tenant A consume'); if ('reason' in a) return softSkip('blocked', a.reason);
115
+ expect(a.status, req(ID, `conformance/fixtures.md §${CONSUME}`, `tenant A's consume run MUST complete (got ${a.status})`)).toBe('completed');
116
+ expect(consumedOf(a.docs) ?? [], req(ID, `conformance/fixtures.md §${CONSUME}`, `positive control: tenant A's own consumer on ${topic} MUST receive the message A published — without it "tenant B received nothing" is also what a queue that delivers nothing shows`)).toContain(nonce);
117
+ }, 90_000);
118
+ });