@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,213 @@
1
+ /**
2
+ * `spec/v2/core/storage.md` §Shared rules — host storage never reads across
3
+ * tenants (target major 2; gated per storage family + the
4
+ * `conformance-storage-probe` fixture + a second-tenant credential).
5
+ *
6
+ * The rule: "A read for one tenant MUST NOT return data another tenant wrote,
7
+ * even under an identical key or name: `kvStorage` `get` and `list`,
8
+ * `tableStorage` `get` and `query`, `vectorStore` and `searchIndex` `query`,
9
+ * `blobStorage` and `cache` `get`" — and `sql` / `nosql` datasources are
10
+ * scoped per tenant. Until this file it had a witness only at major 1 (the v1
11
+ * `test/surface` seam, four families); `vectorStore`, `searchIndex`, `sql` and
12
+ * `nosql` had none at any major.
13
+ *
14
+ * No seam. Each leg drives the ordinary run surface with the
15
+ * `conformance-storage-probe` fixture (conformance/fixtures.md §"The storage
16
+ * probe fixtures"), whose node calls the host's own storage service for the
17
+ * run's tenant, under two credentials that bind two different tenants:
18
+ *
19
+ * 1. tenant A (`OPENWOP_API_KEY`) writes a fresh value V at a fresh key K;
20
+ * 2. tenant A reads K — V MUST come back (the positive control: without it a
21
+ * host that ignores the probe passes every leg vacuously);
22
+ * 3. tenant B (`OPENWOP_TEST_TENANT_B_API_KEY`) reads K, handing it A's
23
+ * `ref` (row / document id) too — B's run MUST NOT return V or K. A refusal
24
+ * (`forbidden` / `not_found`, storage.md's refusal codes) is also correct;
25
+ * 4. tenant A deletes K (best-effort cleanup, asserted nothing).
26
+ *
27
+ * One `it` and one requirement id per family, so a failure names its family.
28
+ *
29
+ * Dispositions: family not in the v2 root ⇒ `inapplicable` before anything
30
+ * runs. Family advertised but the probe fixture not advertised, no second
31
+ * credential, a second credential that binds the SAME tenant, or a fixture run
32
+ * that cannot be created ⇒ `blocked` (an advertised isolation claim this run
33
+ * could not observe; RFC 0148 §A, the 2.45.1 rule for a withheld fixture).
34
+ *
35
+ * Sabotage (run against a patched local copy of the v2 reference host that
36
+ * serves these families in memory): key each store by name only — dropping the
37
+ * tenant — and every family's leg fails on step 3; a probe that ignores
38
+ * `write` fails step 2.
39
+ *
40
+ * @see spec/v2/core/storage.md §Shared rules
41
+ * @see conformance/fixtures.md §"The storage probe fixtures (storage.md)"
42
+ */
43
+
44
+ import { describe, it, expect } from 'vitest';
45
+ import { randomUUID } from 'node:crypto';
46
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
47
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
48
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
49
+ import { readErrorCode } from '../lib/error-envelope.js';
50
+ import { softSkip } from '../lib/soft-skip.js';
51
+ import { req } from '../lib/requirement-ids.js';
52
+
53
+ const FIXTURE = 'conformance-storage-probe';
54
+ const NODE_ID = 'storage-probe';
55
+ const DOC = 'spec/v2/core/storage.md §Shared rules';
56
+ const ID_KV = 'openwop.requirement.storage.cross-tenant-kv-storage';
57
+ const ID_TABLE = 'openwop.requirement.storage.cross-tenant-table-storage';
58
+ const ID_BLOB = 'openwop.requirement.storage.cross-tenant-blob-storage';
59
+ const ID_CACHE = 'openwop.requirement.storage.cross-tenant-cache';
60
+ const ID_VECTOR = 'openwop.requirement.storage.cross-tenant-vector-store';
61
+ const ID_SEARCH = 'openwop.requirement.storage.cross-tenant-search-index';
62
+ const ID_SQL = 'openwop.requirement.storage.cross-tenant-sql';
63
+ const ID_NOSQL = 'openwop.requirement.storage.cross-tenant-nosql';
64
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
65
+ /** storage.md: "A refused call carries `not_found`, `forbidden`, `validation_error`, or `storage_limit_exceeded`". */
66
+ const REFUSALS = new Set(['not_found', 'forbidden', 'validation_error', 'storage_limit_exceeded']);
67
+
68
+ type Family = 'kvStorage' | 'tableStorage' | 'blobStorage' | 'cache' | 'vectorStore' | 'searchIndex' | 'sql' | 'nosql';
69
+ type As = { authenticated?: boolean; headers?: Record<string, string> };
70
+
71
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
72
+ const enc = (id: string): string => encodeURIComponent(id);
73
+ const tenantOf = (runId: string): string => runId.slice(0, Math.max(0, runId.indexOf('/')));
74
+ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
75
+
76
+ interface Probe {
77
+ readonly runId: string;
78
+ readonly status: string;
79
+ readonly result: Record<string, unknown> | null;
80
+ readonly errorCode: string | null;
81
+ }
82
+
83
+ /** Run the probe fixture with `inputs` as the caller `as` names, and read the node's terminal event. */
84
+ async function probe(inputs: Record<string, unknown>, as: As = {}): Promise<Probe | { reason: string }> {
85
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs }, as));
86
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
87
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
88
+ 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() };
89
+ const t0 = Date.now(); let status = '';
90
+ while (Date.now() - t0 < 30_000) {
91
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`, as));
92
+ status = String((snap?.json as { status?: unknown } | null)?.status ?? '');
93
+ if (TERMINAL.has(status)) break;
94
+ await new Promise((r) => setTimeout(r, 150));
95
+ }
96
+ if (!TERMINAL.has(status)) return { reason: `the ${FIXTURE} run did not reach a terminal status within 30 s (last: ${status || 'unreadable'})` };
97
+ const ev = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`, as));
98
+ const events = (ev?.json as { events?: unknown } | null)?.events;
99
+ if (ev?.status !== 200 || !Array.isArray(events)) return { reason: `GET /runs/{runId}/events/poll answered ${ev?.status ?? 'nothing'}` };
100
+ 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;
101
+ const outputs = (node('node.completed')?.['outputs'] ?? {}) as Record<string, unknown>;
102
+ const result = outputs['result'];
103
+ const error = (node('node.failed')?.['error'] ?? null) as { code?: unknown } | null;
104
+ return { runId, status, result: result && typeof result === 'object' && !Array.isArray(result) ? (result as Record<string, unknown>) : null, errorCode: typeof error?.code === 'string' ? error.code : null };
105
+ }
106
+
107
+ interface Observed {
108
+ readonly value: string;
109
+ readonly key: string;
110
+ /** Tenant A's own read of K: the values it returned. */
111
+ readonly control: Probe;
112
+ /** Tenant B's read of the same K (and A's ref). */
113
+ readonly other: Probe;
114
+ }
115
+
116
+ /**
117
+ * Steps 1–4 for one family, or the recorded reason the leg cannot run. Every
118
+ * soft-skip decision is made here, before the calling `it` asserts anything.
119
+ */
120
+ async function isolationLeg(family: Family): Promise<Observed | { skip: ['inapplicable' | 'blocked', string] }> {
121
+ if (!(await v2Discovery().catch(() => null))) return { skip: ['blocked', 'v2 discovery unreachable'] };
122
+ if (!(await familyAdvertised(family))) return { skip: ['inapplicable', `${family} is not advertised in the v2 discovery root — the host exposes no ${family} to pack code`] };
123
+ if (!isFixtureAdvertised(FIXTURE)) return { skip: ['blocked', `the host advertises ${family} but not the ${FIXTURE} fixture — the tenant-isolation claim is made and cannot be observed without it`] };
124
+ const otherKey = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
125
+ if (!otherKey) return { skip: ['blocked', `OPENWOP_TEST_TENANT_B_API_KEY is not set — ${family} tenant isolation needs a caller bound to a second tenant`] };
126
+ const asB: As = { authenticated: false, headers: { Authorization: `Bearer ${otherKey}` } };
127
+
128
+ const nonce = randomUUID().replace(/-/g, '');
129
+ const key = `openwop-conformance-xtenant-${nonce.slice(0, 16)}`;
130
+ const value = `openwop-conformance-secret-${nonce}`;
131
+ const wrote = await probe({ family, action: 'write', key, value });
132
+ if ('reason' in wrote) return { skip: ['blocked', `tenant A's ${family} write: ${wrote.reason}`] };
133
+ const ref = typeof wrote.result?.['ref'] === 'string' ? wrote.result['ref'] : undefined;
134
+ const readInputs = { family, action: 'read', key, ...(family === 'searchIndex' ? { value } : {}), ...(ref === undefined ? {} : { ref }) };
135
+ const control = await probe(readInputs);
136
+ if ('reason' in control) return { skip: ['blocked', `tenant A's ${family} read: ${control.reason}`] };
137
+ const other = await probe(readInputs, asB);
138
+ if ('reason' in other) return { skip: ['blocked', `tenant B could not run the ${FIXTURE} fixture: ${other.reason}`] };
139
+ if (tenantOf(other.runId) === tenantOf(control.runId)) return { skip: ['blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant'] };
140
+ await probe({ family, action: 'delete', key, ...(ref === undefined ? {} : { ref }) });
141
+ return { value, key, control, other };
142
+ }
143
+
144
+ const leakedValues = (o: Observed): string[] => strings(o.other.result?.['values']).filter((v) => v.includes(o.value));
145
+ const leakedKeys = (o: Observed): string[] => strings(o.other.result?.['keys']).filter((k) => k === o.key);
146
+ /** A refused read is a correct answer too; anything else must have completed. */
147
+ const otherOutcome = (o: Observed): string => (o.other.status === 'completed' ? 'completed' : REFUSALS.has(o.other.errorCode ?? '') ? 'refused' : `${o.other.status} ${o.other.errorCode ?? '(no node.failed code)'}`);
148
+
149
+ describe('v2 storage cross-tenant isolation (storage.md §Shared rules)', () => {
150
+ it('kvStorage: a read for tenant B never returns what tenant A wrote under the same key (get and list)', async () => {
151
+ const o = await isolationLeg('kvStorage'); if ('skip' in o) return softSkip(...o.skip);
152
+ expect(strings(o.control.result?.['values']), req(ID_KV, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own read MUST return the value it wrote (positive control)")).toContain(o.value);
153
+ expect(otherOutcome(o), req(ID_KV, DOC, "tenant B's read MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
154
+ expect(leakedValues(o), req(ID_KV, DOC, "a kvStorage get for tenant B MUST NOT return data tenant A wrote under an identical key")).toEqual([]);
155
+ expect(leakedKeys(o), req(ID_KV, DOC, "a kvStorage list for tenant B MUST NOT return a key tenant A wrote")).toEqual([]);
156
+ }, 150_000);
157
+
158
+ it('tableStorage: a read for tenant B never returns what tenant A wrote in the same table (get and query)', async () => {
159
+ const o = await isolationLeg('tableStorage'); if ('skip' in o) return softSkip(...o.skip);
160
+ expect(strings(o.control.result?.['values']), req(ID_TABLE, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own read MUST return the value it wrote (positive control)")).toContain(o.value);
161
+ expect(otherOutcome(o), req(ID_TABLE, DOC, "tenant B's read MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
162
+ expect(leakedValues(o), req(ID_TABLE, DOC, "a tableStorage get or query for tenant B MUST NOT return a row tenant A wrote in the identically named table")).toEqual([]);
163
+ expect(leakedKeys(o), req(ID_TABLE, DOC, "a tableStorage query for tenant B MUST NOT return a row tenant A wrote")).toEqual([]);
164
+ }, 150_000);
165
+
166
+ it('blobStorage: a read for tenant B never returns what tenant A wrote under the same bucket and key', async () => {
167
+ const o = await isolationLeg('blobStorage'); if ('skip' in o) return softSkip(...o.skip);
168
+ expect(strings(o.control.result?.['values']), req(ID_BLOB, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own read MUST return the object it wrote (positive control)")).toContain(o.value);
169
+ expect(otherOutcome(o), req(ID_BLOB, DOC, "tenant B's read MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
170
+ expect(leakedValues(o), req(ID_BLOB, DOC, "a blobStorage get for tenant B MUST NOT return an object tenant A wrote under an identical bucket and key")).toEqual([]);
171
+ expect(leakedKeys(o), req(ID_BLOB, DOC, "a blobStorage list for tenant B MUST NOT return a key tenant A wrote")).toEqual([]);
172
+ }, 150_000);
173
+
174
+ it('cache: a get for tenant B never returns what tenant A put under the same key', async () => {
175
+ const o = await isolationLeg('cache'); if ('skip' in o) return softSkip(...o.skip);
176
+ expect(strings(o.control.result?.['values']), req(ID_CACHE, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own read MUST return the value it put (positive control)")).toContain(o.value);
177
+ expect(otherOutcome(o), req(ID_CACHE, DOC, "tenant B's read MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
178
+ expect(leakedValues(o), req(ID_CACHE, DOC, "a cache get for tenant B MUST NOT return data tenant A wrote under an identical key")).toEqual([]);
179
+ expect(leakedKeys(o), req(ID_CACHE, DOC, "a cache read for tenant B MUST NOT return a key tenant A wrote")).toEqual([]);
180
+ }, 150_000);
181
+
182
+ it('vectorStore: a query for tenant B never matches a vector tenant A upserted in the same collection', async () => {
183
+ const o = await isolationLeg('vectorStore'); if ('skip' in o) return softSkip(...o.skip);
184
+ expect(strings(o.control.result?.['values']), req(ID_VECTOR, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own query MUST return the vector it upserted (positive control)")).toContain(o.value);
185
+ expect(otherOutcome(o), req(ID_VECTOR, DOC, "tenant B's query MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
186
+ expect(leakedValues(o), req(ID_VECTOR, DOC, "a vectorStore query for tenant B MUST NOT return metadata tenant A wrote in the identically named collection")).toEqual([]);
187
+ expect(leakedKeys(o), req(ID_VECTOR, DOC, "a vectorStore query for tenant B MUST NOT return an id tenant A upserted")).toEqual([]);
188
+ }, 150_000);
189
+
190
+ it('searchIndex: a query for tenant B never hits a document tenant A indexed in the same index', async () => {
191
+ const o = await isolationLeg('searchIndex'); if ('skip' in o) return softSkip(...o.skip);
192
+ expect(strings(o.control.result?.['values']), req(ID_SEARCH, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own query MUST return the document it indexed (positive control)")).toContain(o.value);
193
+ expect(otherOutcome(o), req(ID_SEARCH, DOC, "tenant B's query MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
194
+ expect(leakedValues(o), req(ID_SEARCH, DOC, "a searchIndex query for tenant B MUST NOT return fields tenant A indexed in the identically named index")).toEqual([]);
195
+ expect(leakedKeys(o), req(ID_SEARCH, DOC, "a searchIndex query for tenant B MUST NOT return an id tenant A indexed")).toEqual([]);
196
+ }, 150_000);
197
+
198
+ it('sql: tenant B reading the same-named datasource never sees a row tenant A inserted', async () => {
199
+ const o = await isolationLeg('sql'); if ('skip' in o) return softSkip(...o.skip);
200
+ expect(strings(o.control.result?.['values']), req(ID_SQL, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own query MUST return the row it inserted (positive control)")).toContain(o.value);
201
+ expect(otherOutcome(o), req(ID_SQL, DOC, "tenant B's query MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
202
+ expect(leakedValues(o), req(ID_SQL, DOC, "sql datasources are scoped per tenant: tenant B MUST NOT read a row tenant A inserted")).toEqual([]);
203
+ expect(leakedKeys(o), req(ID_SQL, DOC, "sql datasources are scoped per tenant: tenant B MUST NOT see a key tenant A inserted")).toEqual([]);
204
+ }, 150_000);
205
+
206
+ it('nosql: tenant B reading the same-named datasource never sees a document tenant A inserted (get and query)', async () => {
207
+ const o = await isolationLeg('nosql'); if ('skip' in o) return softSkip(...o.skip);
208
+ expect(strings(o.control.result?.['values']), req(ID_NOSQL, 'conformance/fixtures.md §The storage probe fixtures', "tenant A's own query MUST return the document it inserted (positive control)")).toContain(o.value);
209
+ expect(otherOutcome(o), req(ID_NOSQL, DOC, "tenant B's read MUST complete or be refused with a storage refusal code")).toMatch(/^(completed|refused)$/);
210
+ expect(leakedValues(o), req(ID_NOSQL, DOC, "nosql datasources are scoped per tenant: tenant B MUST NOT read a document tenant A inserted, by query or by its id")).toEqual([]);
211
+ expect(leakedKeys(o), req(ID_NOSQL, DOC, "nosql datasources are scoped per tenant: tenant B MUST NOT see a key tenant A inserted")).toEqual([]);
212
+ }, 150_000);
213
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `spec/v2/core/host-services.md` §`toolHooks` — per-tool authorization fails
3
+ * closed (target major 2; gated on `toolHooks.perToolAuthorization` and the
4
+ * `conformance-tool-scope-probe` fixture).
5
+ *
6
+ * "Before invoking, the host MUST check the principal's scopes against the
7
+ * tool's `requiredScopes`. If one is missing or cannot be evaluated, it MUST
8
+ * NOT invoke, MUST emit `agent.toolReturned` with `status: forbidden`, and
9
+ * MUST answer `403 forbidden` with `details.scope: "tool"`, `toolName` and
10
+ * `requiredScopes`."
11
+ *
12
+ * Before this file the rule had a witness at major 1 only
13
+ * (`tool-hooks-authorization-fail-closed`), through a v1 seam that returned the
14
+ * host's own account of what it did. It never observed the tool.
15
+ *
16
+ * No seam. The fixture's node (`core.conformance.scoped-tool`,
17
+ * conformance/fixtures.md §`conformance-tool-scope-probe`) makes ONE external
18
+ * tool call through the host's ordinary tool path as the run's principal. The
19
+ * tool, when invoked, POSTs to a URL the suite hands it, so "not invoked" is
20
+ * counted at the suite's own receiver, not taken from the host's log:
21
+ *
22
+ * 0. control — no required scope: the tool MUST be invoked (one arrival,
23
+ * `agent.tool-returned { status: ok }`). Without it a host that refuses
24
+ * every tool, or a node that never calls out, passes both legs vacuously.
25
+ * 1. `missing-scope` — `requiredScopes: ["conformance:never-granted"]`, a
26
+ * scope the operator grants nobody;
27
+ * 2. `unevaluable-scope` — `requiredScopes: ["conformance:unevaluable"]`,
28
+ * whose check the operator binds to error.
29
+ *
30
+ * For 1 and 2: the receiver sees NO call; the log carries
31
+ * `agent.tool-returned { toolName, status: forbidden }` with no `durationMs`
32
+ * (the call never started) and no `ok` return for the tool; and the refusal,
33
+ * which a run surfaces as the node's failure (`errors.md` §Host-service
34
+ * refusals), is `forbidden` with `details.scope: "tool"`, `details.toolName`
35
+ * and `details.requiredScopes` naming the scope.
36
+ *
37
+ * Dispositions: `toolHooks` not in the v2 root, or `perToolAuthorization` not
38
+ * `true` ⇒ `inapplicable`, read with `familyAdvertised` rather than
39
+ * `gateFamily` so strict mode does not demand a `family.toolHooks` opt-out from
40
+ * a host that never claimed the facet. Facet advertised without the fixture, a
41
+ * fixture run that cannot be created or does not settle, or a control that
42
+ * returns `ok` but never reaches the receiver (a loopback receiver the host
43
+ * cannot address — set `OPENWOP_WEBHOOK_RECEIVER_URL`) ⇒ `blocked` (RFC 0148 §A).
44
+ *
45
+ * Sabotage (a patched local copy of the v2 reference host): treat an
46
+ * unevaluable scope as granted, and the `unevaluable-scope` leg fails on the
47
+ * receiver; skip the check entirely, and both legs fail; refuse with
48
+ * `forbidden` but still call the tool, and both fail on the receiver.
49
+ *
50
+ * @see spec/v2/core/host-services.md §toolHooks
51
+ * @see conformance/fixtures.md §`conformance-tool-scope-probe`
52
+ */
53
+
54
+ import { describe, it, expect } from 'vitest';
55
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
56
+ import { familyAdvertised, v2Discovery } from '../lib/v2.js';
57
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
58
+ import { readErrorCode } from '../lib/error-envelope.js';
59
+ import { softSkip } from '../lib/soft-skip.js';
60
+ import { req } from '../lib/requirement-ids.js';
61
+ import { startEffectReceiver, waitForFirstArrival } from '../lib/effect-receiver.js';
62
+
63
+ export const REQUIRES_HOST_CALLBACK = 'the host invokes the scope-probe tool, which calls the suite-owned effect receiver (OPENWOP_WEBHOOK_RECEIVER_PORT)';
64
+
65
+ const FIXTURE = 'conformance-tool-scope-probe';
66
+ const TOOL = 'conformance.scope-probe';
67
+ const DOC = 'spec/v2/core/host-services.md §toolHooks';
68
+ const FIXTURE_DOC = 'conformance/fixtures.md §conformance-tool-scope-probe';
69
+ const ID_MISSING = 'openwop.requirement.toolHooks.authorization-fail-closed-missing-scope';
70
+ const ID_UNEVALUABLE = 'openwop.requirement.toolHooks.authorization-fail-closed-unevaluable-scope';
71
+ const NEVER_GRANTED = 'conformance:never-granted';
72
+ const UNEVALUABLE = 'conformance:unevaluable';
73
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
74
+ /** After a refused run settles, how long a late call to the receiver is still waited for. */
75
+ const QUIET_MS = 2_000;
76
+
77
+ type Skip = { skip: ['inapplicable' | 'blocked', string] };
78
+ interface Ev { type?: unknown; nodeId?: unknown; payload?: Record<string, unknown> }
79
+
80
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
81
+ const enc = (id: string): string => encodeURIComponent(id);
82
+
83
+ async function gate(): Promise<Skip | null> {
84
+ if (!(await v2Discovery().catch(() => null))) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 under OpenWOP-Version: 2.0'] };
85
+ const hooks = await familyAdvertised('toolHooks');
86
+ if (!hooks) return { skip: ['inapplicable', 'toolHooks is not advertised in the v2 discovery root — no per-tool authorization obligation'] };
87
+ if (hooks['perToolAuthorization'] !== true) return { skip: ['inapplicable', 'toolHooks is advertised without perToolAuthorization: true — the host makes no per-tool authorization claim'] };
88
+ if (!isFixtureAdvertised(FIXTURE)) return { skip: ['blocked', `the host advertises toolHooks.perToolAuthorization but not the ${FIXTURE} fixture — the fail-closed claim is made and cannot be observed without it`] };
89
+ return null;
90
+ }
91
+
92
+ interface Exercise {
93
+ readonly runId: string;
94
+ readonly status: string;
95
+ readonly arrivals: number;
96
+ readonly tunnelled: boolean;
97
+ readonly receiver: string;
98
+ /** `agent.tool-returned` payloads for the probe tool. */
99
+ readonly returns: Array<Record<string, unknown>>;
100
+ /** The node's `node.failed` error, if it failed. */
101
+ readonly error: { code?: unknown; details?: Record<string, unknown> } | null;
102
+ }
103
+
104
+ /** Run the fixture once, against a fresh receiver, and read back what reached the receiver and what the log says. */
105
+ async function exercise(requiredScope: string): Promise<Exercise | { reason: string }> {
106
+ const rx = await startEffectReceiver();
107
+ try {
108
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: { requiredScope, url: rx.url } }));
109
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
110
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
111
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${FIXTURE}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the advertised fixture did not start`.trim() };
112
+ const t0 = Date.now(); let status = '';
113
+ while (Date.now() - t0 < 30_000) {
114
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
115
+ status = String((snap?.json as { status?: unknown } | null)?.status ?? '');
116
+ if (TERMINAL.has(status)) break;
117
+ await new Promise((r) => setTimeout(r, 150));
118
+ }
119
+ if (!TERMINAL.has(status)) return { reason: `the ${FIXTURE} run did not reach a terminal status within 30 s (last: ${status || 'unreadable'})` };
120
+ // A tool call that the host made is on the wire before the run settles, but give a slow network the benefit.
121
+ if (requiredScope === '') await waitForFirstArrival(rx, 5_000);
122
+ else await new Promise((r) => setTimeout(r, QUIET_MS));
123
+ const poll = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
124
+ const events = (poll?.json as { events?: unknown } | null)?.events;
125
+ if (poll?.status !== 200 || !Array.isArray(events)) return { reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'nothing'}` };
126
+ const evs = events as Ev[];
127
+ const returns = evs.filter((e) => e.type === 'agent.tool-returned' && e.payload?.['toolName'] === TOOL).map((e) => e.payload as Record<string, unknown>);
128
+ const failed = evs.find((e) => e.type === 'node.failed');
129
+ const error = (failed?.payload?.['error'] ?? null) as Exercise['error'];
130
+ return { runId, status, arrivals: rx.arrivals(), tunnelled: rx.tunnelled, receiver: rx.url, returns, error };
131
+ } finally {
132
+ await rx.close();
133
+ }
134
+ }
135
+
136
+ /** The control run, once per file: the tool IS invoked when it requires nothing. */
137
+ let control: Promise<Exercise | { reason: string }> | null = null;
138
+ function controlRun(): Promise<Exercise | { reason: string }> {
139
+ control ??= exercise('');
140
+ return control;
141
+ }
142
+
143
+ /** Everything a scoped leg needs, or the recorded reason it cannot run. Every soft-skip decision is made here. */
144
+ async function leg(scope: string): Promise<{ control: Exercise; denied: Exercise } | Skip> {
145
+ const why = await gate(); if (why) return why;
146
+ const c = await controlRun();
147
+ if ('reason' in c) return { skip: ['blocked', `control run: ${c.reason}`] };
148
+ const ok = c.status === 'completed' && c.returns.some((r) => r['status'] === 'ok');
149
+ if (ok && c.arrivals === 0) {
150
+ return { skip: ['blocked', `the control run invoked ${TOOL} (status ok) but nothing reached the suite's receiver at ${c.receiver}${c.tunnelled ? '' : ' (a loopback address — set OPENWOP_WEBHOOK_RECEIVER_URL to a front the host can reach)'}; "not invoked" cannot be counted`] };
151
+ }
152
+ const d = await exercise(scope);
153
+ if ('reason' in d) return { skip: ['blocked', `${scope} run: ${d.reason}`] };
154
+ return { control: c, denied: d };
155
+ }
156
+
157
+ describe('v2 tool authorization fail-closed (host-services.md §toolHooks)', () => {
158
+ it('a tool whose required scope the principal lacks is not invoked and is refused forbidden', async () => {
159
+ const o = await leg(NEVER_GRANTED); if ('skip' in o) return softSkip(...o.skip);
160
+ const { control: c, denied: d } = o;
161
+ // Positive control: without it, a host that refuses every tool passes vacuously.
162
+ expect(`${c.status}/${c.returns.map((r) => String(r['status'])).join(',') || 'no tool-returned'}`, req(ID_MISSING, FIXTURE_DOC, `with no required scope the fixture MUST invoke ${TOOL} and complete with agent.tool-returned status ok (positive control)`)).toMatch(/^completed\/(.*,)?ok(,.*)?$/);
163
+ expect(c.arrivals, req(ID_MISSING, FIXTURE_DOC, `with no required scope ${TOOL} MUST reach the suite's receiver (positive control)`)).toBeGreaterThan(0);
164
+
165
+ expect(d.arrivals, req(ID_MISSING, DOC, `a tool whose required scope (${NEVER_GRANTED}) the principal lacks or that cannot be evaluated MUST NOT be invoked — the suite's receiver saw ${d.arrivals} call(s)`)).toBe(0);
166
+ const forbidden = d.returns.filter((r) => r['status'] === 'forbidden');
167
+ expect(forbidden.length, req(ID_MISSING, DOC, `the host MUST emit agent.tool-returned with status: forbidden for ${TOOL} (got statuses: ${JSON.stringify(d.returns.map((r) => r['status'] ?? null))})`)).toBeGreaterThan(0);
168
+ expect(d.returns.filter((r) => r['status'] === 'ok').length, req(ID_MISSING, DOC, `a refused tool MUST NOT also return ok`)).toBe(0);
169
+ expect(forbidden.filter((r) => r['durationMs'] !== undefined).length, req(ID_MISSING, 'schemas/v2/run-event-payloads.schema.json#/$defs/agentToolReturned', 'durationMs is absent when the call never started (forbidden)')).toBe(0);
170
+
171
+ const details = d.error?.details ?? {};
172
+ expect(d.error?.code, req(ID_MISSING, DOC, `the refusal MUST be forbidden (the run surfaces it as the node's failure, errors.md §Host-service refusals; run status ${d.status})`)).toBe('forbidden');
173
+ expect(details['scope'], req(ID_MISSING, DOC, 'the forbidden refusal MUST carry details.scope: "tool"')).toBe('tool');
174
+ expect(details['toolName'], req(ID_MISSING, DOC, 'the forbidden refusal MUST carry details.toolName')).toBe(TOOL);
175
+ expect(Array.isArray(details['requiredScopes']) ? (details['requiredScopes'] as unknown[]) : [], req(ID_MISSING, DOC, 'the forbidden refusal MUST carry details.requiredScopes naming the tool\'s scopes')).toContain(NEVER_GRANTED);
176
+ }, 90_000);
177
+
178
+ it('a tool whose required scope cannot be evaluated is not invoked and is refused forbidden', async () => {
179
+ const o = await leg(UNEVALUABLE); if ('skip' in o) return softSkip(...o.skip);
180
+ const { control: c, denied: d } = o;
181
+ // Positive control: without it, a host that refuses every tool passes vacuously.
182
+ expect(`${c.status}/${c.returns.map((r) => String(r['status'])).join(',') || 'no tool-returned'}`, req(ID_UNEVALUABLE, FIXTURE_DOC, `with no required scope the fixture MUST invoke ${TOOL} and complete with agent.tool-returned status ok (positive control)`)).toMatch(/^completed\/(.*,)?ok(,.*)?$/);
183
+ expect(c.arrivals, req(ID_UNEVALUABLE, FIXTURE_DOC, `with no required scope ${TOOL} MUST reach the suite's receiver (positive control)`)).toBeGreaterThan(0);
184
+
185
+ expect(d.arrivals, req(ID_UNEVALUABLE, DOC, `a tool whose required scope (${UNEVALUABLE}) the principal lacks or that cannot be evaluated MUST NOT be invoked — the suite's receiver saw ${d.arrivals} call(s)`)).toBe(0);
186
+ const forbidden = d.returns.filter((r) => r['status'] === 'forbidden');
187
+ expect(forbidden.length, req(ID_UNEVALUABLE, DOC, `the host MUST emit agent.tool-returned with status: forbidden for ${TOOL} (got statuses: ${JSON.stringify(d.returns.map((r) => r['status'] ?? null))})`)).toBeGreaterThan(0);
188
+ expect(d.returns.filter((r) => r['status'] === 'ok').length, req(ID_UNEVALUABLE, DOC, `a refused tool MUST NOT also return ok`)).toBe(0);
189
+ expect(forbidden.filter((r) => r['durationMs'] !== undefined).length, req(ID_UNEVALUABLE, 'schemas/v2/run-event-payloads.schema.json#/$defs/agentToolReturned', 'durationMs is absent when the call never started (forbidden)')).toBe(0);
190
+
191
+ const details = d.error?.details ?? {};
192
+ expect(d.error?.code, req(ID_UNEVALUABLE, DOC, `the refusal MUST be forbidden (the run surfaces it as the node's failure, errors.md §Host-service refusals; run status ${d.status})`)).toBe('forbidden');
193
+ expect(details['scope'], req(ID_UNEVALUABLE, DOC, 'the forbidden refusal MUST carry details.scope: "tool"')).toBe('tool');
194
+ expect(details['toolName'], req(ID_UNEVALUABLE, DOC, 'the forbidden refusal MUST carry details.toolName')).toBe(TOOL);
195
+ expect(Array.isArray(details['requiredScopes']) ? (details['requiredScopes'] as unknown[]) : [], req(ID_UNEVALUABLE, DOC, 'the forbidden refusal MUST carry details.requiredScopes naming the tool\'s scopes')).toContain(UNEVALUABLE);
196
+ }, 90_000);
197
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `v2-workspace-scope-from-identity` (target major 2; gated on the `workspace`
3
+ * family and the `openwop-conformance-seams-v2` profile).
4
+ *
5
+ * `spec/v2/core/host-services.md` §`workspace`: a host advertising `workspace`
6
+ * "MUST derive the scope from the authenticated identity and MUST NOT return or
7
+ * disclose another scope's file; `404` MAY stand for `403` (invariant
8
+ * `workspace-cross-tenant-isolation`)". No protocol path serves workspace files,
9
+ * so the only observation surface is the EXISTING v2 seam
10
+ * `/conformance/seams/workspace/files[/{path}]` (`api/seams-v2.yaml`). This file
11
+ * adds no seam; it drives that one with two credentials.
12
+ *
13
+ * Until this file the rule had major-1 witnesses only (`workspace-cross-tenant-
14
+ * isolation`, `workspace-cross-tenant-isolation-blackbox`, both on `/v1/…`), so
15
+ * no v2 host was ever measured on it (docs/V2-WITNESS-COVERAGE.md risk #3).
16
+ *
17
+ * Legs:
18
+ * 1. tenant A writes a unique file; tenant B (OPENWOP_TEST_TENANT_B_API_KEY)
19
+ * reads it by path → 403/404 with no content, and B's list omits it.
20
+ * Positive control: A reads its own file back and lists it.
21
+ * 2. the same read and list by B, NAMING A's scope in the request
22
+ * (`tenantId` / `workspaceId` query parameters, the values read from
23
+ * `owner` of a run A just created). A host that takes the scope from the
24
+ * request instead of the credential serves A's file here.
25
+ *
26
+ * Dispositions: family or seams profile not advertised ⇒ `inapplicable`
27
+ * (lib/seams.ts: the host has not claimed the instrument). Advertised, but the
28
+ * seam answers 404/405/501, the second-tenant key is absent / identical /
29
+ * refused, or (leg 2) A's scope cannot be read from a run ⇒ `blocked` — the host
30
+ * took the obligation on and the suite could not measure it.
31
+ */
32
+ import { describe, it, expect } from 'vitest';
33
+ import { randomUUID } from 'node:crypto';
34
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
35
+ import { loadEnv } from '../lib/env.js';
36
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
37
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
38
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
39
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
40
+ import { req } from '../lib/requirement-ids.js';
41
+
42
+ const DOC = 'spec/v2/core/host-services.md §workspace';
43
+ const FILES = `${SEAMS_PREFIX}/workspace/files`;
44
+ const NOOP = 'conformance-noop';
45
+ const ID_READ = 'openwop.requirement.0059.workspace-scope-from-identity';
46
+ const ID_NAMED = 'openwop.requirement.0059.workspace-scope-not-request-named';
47
+
48
+ type Gate = { ok: true; asB: { authenticated: false; headers: Record<string, string> } } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string; seam?: true };
49
+
50
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
51
+ try { return await fn(); } catch { return null; }
52
+ }
53
+
54
+ /** Family, profile, seam and second credential — decided before any assertion. */
55
+ async function gate(): Promise<Gate> {
56
+ const doc = await v2Discovery();
57
+ if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
58
+ if (!(await gateFamily('workspace'))) return { ok: false, kind: 'inapplicable', reason: 'workspace family not advertised (gate recorded under openwop.family.workspace)' };
59
+ if (!seamsProfileAdvertised(doc)) return { ok: false, kind: 'inapplicable', reason: 'workspace files have no protocol path; the only observation surface is the seams profile (conformance.seamsProfile = openwop-conformance-seams-v2), which is not advertised' };
60
+ const probe = await http(() => driver.get(FILES));
61
+ if (probe === null) return { ok: false, kind: 'blocked', reason: `GET ${FILES} unreachable (fetch failed)` };
62
+ if ([404, 405, 501].includes(probe.status)) return { ok: false, kind: 'blocked', reason: `host advertises workspace and the seams profile but GET ${FILES} answered ${probe.status}`, seam: true };
63
+ const b = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
64
+ if (!b) return { ok: false, kind: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — no second-scope read can be measured' };
65
+ if (b === loadEnv().apiKey) return { ok: false, kind: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the leg needs a second tenant' };
66
+ const asB = { authenticated: false as const, headers: { Authorization: `Bearer ${b}` } };
67
+ const probeB = await http(() => driver.get(FILES, asB));
68
+ if (probeB === null || probeB.status === 401) return { ok: false, kind: 'blocked', reason: `OPENWOP_TEST_TENANT_B_API_KEY does not authenticate on this host (GET ${FILES} answered ${probeB?.status ?? 'nothing'})` };
69
+ return { ok: true, asB };
70
+ }
71
+
72
+ /** A's file, written through the seam; the caller deletes it. */
73
+ async function writeA(): Promise<{ path: string; secret: string } | { reason: string }> {
74
+ const path = `v2-wsi-${randomUUID()}.md`;
75
+ const secret = `V2-WSI-CANARY-${randomUUID()}`;
76
+ const put = await http(() => driver.put(`${FILES}/${path}`, { content: secret }));
77
+ if (put === null || put.status !== 200) return { reason: `tenant A could not write ${FILES}/${path} (answered ${put?.status ?? 'nothing'})` };
78
+ return { path, secret };
79
+ }
80
+
81
+ function paths(res: OpenWOPResponse | null): string[] {
82
+ const files = (res?.json as { files?: Array<{ path?: unknown }> } | undefined)?.files;
83
+ return Array.isArray(files) ? files.map((f) => String(f?.path)) : [];
84
+ }
85
+
86
+ /** A's own `{tenant, workspace}` as the host records it, from `owner` of a run A creates. */
87
+ async function scopeOf(as: Record<string, unknown> = {}): Promise<{ tenant: string; workspace?: string } | { reason: string }> {
88
+ if (!isFixtureAdvertised(NOOP)) return { reason: `${NOOP} is not advertised — the suite cannot learn a scope id to name` };
89
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP }, as));
90
+ const runId = (res?.json as { runId?: unknown } | undefined)?.runId;
91
+ if (res === null || res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {${NOOP}} answered ${res?.status ?? 'nothing'}` };
92
+ const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, as));
93
+ const owner = (snap?.json as { owner?: { tenant?: unknown; workspace?: unknown } } | undefined)?.owner;
94
+ const tenant = typeof owner?.tenant === 'string' ? owner.tenant : runId.includes('/') ? runId.slice(0, runId.indexOf('/')) : null;
95
+ if (!tenant) return { reason: 'the run snapshot names no owner.tenant and the run id carries no tenant segment' };
96
+ return typeof owner?.workspace === 'string' ? { tenant, workspace: owner.workspace } : { tenant };
97
+ }
98
+
99
+ describe('v2 workspace scope comes from the authenticated identity (host-services.md §workspace)', () => {
100
+ it('a second tenant cannot read or list a file the first tenant wrote', async () => {
101
+ const g = await gate();
102
+ if (!g.ok && g.seam) return seamAbsent(g.reason);
103
+ if (!g.ok) return softSkip(g.kind, g.reason);
104
+ const a = await writeA();
105
+ if ('reason' in a) return softSkip('blocked', a.reason);
106
+ try {
107
+ const own = await driver.get(`${FILES}/${a.path}`);
108
+ expect(own.status, req(ID_READ, DOC, 'positive control: the owning scope MUST read its own file (200)')).toBe(200);
109
+ expect((own.json as { content?: unknown } | undefined)?.content, req(ID_READ, DOC, 'positive control: the owner reads back the content it wrote')).toBe(a.secret);
110
+ expect(paths(await http(() => driver.get(FILES))), req(ID_READ, DOC, 'positive control: the owner\'s list names its own file')).toContain(a.path);
111
+
112
+ const cross = await driver.get(`${FILES}/${a.path}`, g.asB);
113
+ expect([403, 404], req(ID_READ, DOC, `another scope's read MUST NOT return the file — 404 MAY stand for 403 (got ${cross.status})`)).toContain(cross.status);
114
+ expect(cross.text.includes(a.secret), req(ID_READ, DOC, 'another scope\'s read MUST NOT disclose the file\'s content')).toBe(false);
115
+ const list = await http(() => driver.get(FILES, g.asB));
116
+ expect(list?.text.includes(a.secret) === true || paths(list).includes(a.path), req(ID_READ, DOC, 'another scope\'s list MUST NOT disclose the file')).toBe(false);
117
+ } finally {
118
+ await http(() => driver.delete(`${FILES}/${a.path}`));
119
+ }
120
+ });
121
+
122
+ it('naming the first tenant\'s scope in the request does not widen the second tenant\'s scope', async () => {
123
+ const g = await gate();
124
+ if (!g.ok && g.seam) return seamAbsent(g.reason);
125
+ if (!g.ok) return softSkip(g.kind, g.reason);
126
+ const scopeA = await scopeOf();
127
+ if ('reason' in scopeA) return softSkip('blocked', `A's scope id is needed to name it: ${scopeA.reason}`);
128
+ const scopeB = await scopeOf(g.asB);
129
+ if (!('reason' in scopeB) && scopeB.tenant === scopeA.tenant) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant');
130
+ const a = await writeA();
131
+ if ('reason' in a) return softSkip('blocked', a.reason);
132
+ try {
133
+ const q = new URLSearchParams({ tenantId: scopeA.tenant, tenant: scopeA.tenant, ...(scopeA.workspace ? { workspaceId: scopeA.workspace, workspace: scopeA.workspace } : {}) }).toString();
134
+ const own = await driver.get(`${FILES}/${a.path}`);
135
+ expect(own.status, req(ID_NAMED, DOC, 'positive control: the owning scope MUST read its own file (200)')).toBe(200);
136
+
137
+ const cross = await driver.get(`${FILES}/${a.path}?${q}`, g.asB);
138
+ expect([400, 403, 404], req(ID_NAMED, DOC, `the scope MUST come from the credential, not a request-named tenantId/workspaceId (got ${cross.status})`)).toContain(cross.status);
139
+ expect(cross.text.includes(a.secret), req(ID_NAMED, DOC, 'a request naming another scope MUST NOT disclose that scope\'s file')).toBe(false);
140
+ const list = await http(() => driver.get(`${FILES}?${q}`, g.asB));
141
+ expect(list?.text.includes(a.secret) === true || paths(list).includes(a.path), req(ID_NAMED, DOC, 'a list naming another scope MUST NOT disclose that scope\'s file')).toBe(false);
142
+ } finally {
143
+ await http(() => driver.delete(`${FILES}/${a.path}`));
144
+ }
145
+ });
146
+ });