@openwop/openwop-conformance 2.45.2 → 2.45.4

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 (56) hide show
  1. package/CHANGELOG.md +77 -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 +60 -9
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/fixtures/conformance-budget-tool-calls.json +81 -0
  10. package/fixtures/conformance-fs-probe.json +25 -0
  11. package/fixtures/conformance-queue-consume.json +27 -0
  12. package/fixtures/conformance-queue-publish.json +29 -0
  13. package/fixtures/conformance-safefetch-probe.json +23 -0
  14. package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
  15. package/fixtures/conformance-storage-probe.json +27 -0
  16. package/fixtures/conformance-tool-scope-probe.json +27 -0
  17. package/fixtures/openwop-secrets-run-witness.json +29 -0
  18. package/fixtures.md +113 -1
  19. package/package.json +2 -2
  20. package/requirement-aliases.json +72 -71
  21. package/requirements.json +1081 -43
  22. package/scenario-majors.json +45 -3
  23. package/schemas/CORPUS-STAMP.json +22 -22
  24. package/src/cli.ts +1 -1
  25. package/src/lib/backpressure-witness.ts +163 -0
  26. package/src/lib/budget-witness.ts +165 -0
  27. package/src/lib/certification-bundle-verify.ts +23 -2
  28. package/src/lib/driver.ts +61 -2
  29. package/src/lib/major-profile.ts +93 -0
  30. package/src/lib/memoryAttribution.ts +41 -6
  31. package/src/lib/polling.ts +2 -18
  32. package/src/lib/profiles.ts +26 -2
  33. package/src/lib/requirement-registry.ts +12 -0
  34. package/src/lib/run-secrets-witness.ts +240 -0
  35. package/src/lib/scenario-disposition.ts +54 -7
  36. package/src/lib/scratch-host.ts +130 -0
  37. package/src/lib/secret-scan.ts +141 -0
  38. package/src/lib/timeout-scale.ts +25 -0
  39. package/src/lib/triggerBridge.ts +69 -1
  40. package/src/scenarios/byok-roundtrip.test.ts +20 -4
  41. package/src/scenarios/runner-ledger.test.ts +2 -0
  42. package/src/scenarios/secrets-run-witness.test.ts +154 -0
  43. package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
  44. package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
  45. package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
  46. package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
  47. package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
  48. package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
  49. package/src/scenarios/v2-production-backpressure.test.ts +74 -0
  50. package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
  51. package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
  52. package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
  53. package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
  54. package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
  55. package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
  56. package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * RFC 0229 — a production host can witness secret resolution without an oracle
3
+ * (`spec/v2/core/host-services.md` §Run-supplied secrets; suite 2.45.3, target
4
+ * major 2; gated on the `secrets.runSecrets` facet and the
5
+ * `openwop-secrets-run-witness` fixture). The v1 twin is `secrets-run-witness`.
6
+ *
7
+ * The client supplies a fresh value `C` with the run (`runSecrets`, under the
8
+ * reserved `run:openwop-witness` ref) and `sha256(C)` as an input. The
9
+ * `core.secret.witness` node outputs only `{ matched }`. Four rows:
10
+ *
11
+ * 1. `run-witness-resolves` — `matched: true` for `C`, and `matched: false`
12
+ * for a run whose `expectedSha256` names a different value (the second run
13
+ * binds the witness to the exact bytes; the canary fixture never could);
14
+ * 2. `run-witness-redacted` — `C` is absent, in every encoding and as
15
+ * `sha256(C)`, from the create answer, snapshot, polled and `debug`-mode
16
+ * events, the run list and the debug bundle where served;
17
+ * 3. `run-witness-scope-bound` — a non-`run:` ref fails `credential_forbidden`;
18
+ * an unsupplied `run:` ref and an earlier run's ref fail
19
+ * `credential_not_found`; where `replay` advertises `branch`, a fork of the
20
+ * first run fails `credential_not_found` (a fork does not inherit);
21
+ * 4. `run-secrets-outside-request-digest` — a same-key retry differing only in
22
+ * `runSecrets` replays (same `runId`, `OpenWOP-Idempotent-Replay: true`),
23
+ * never `409 idempotency_key_mismatch`, and the retried value `C′` appears
24
+ * on no surface of the run.
25
+ *
26
+ * Dispositions (RFC 0229 §F): facet absent ⇒ `inapplicable`; facet without the
27
+ * fixture ⇒ `blocked`; a `createRun` that refuses a well-formed `runSecrets` ⇒
28
+ * `executed-fail`.
29
+ *
30
+ * How it FAILS (negative controls): a witness that resolves a non-`run:` ref
31
+ * (the oracle) fails row 3; a host that echoes `C` or its digest anywhere fails
32
+ * row 2; a host that folds `runSecrets` into the idempotency digest fails row 4.
33
+ *
34
+ * @see spec/v2/core/host-services.md §Run-supplied secrets
35
+ * @see conformance/fixtures.md §openwop-secrets-run-witness
36
+ * @see RFCS/0229-production-safe-secrets-witness.md
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { randomUUID } from 'node:crypto';
41
+ import { req } from '../lib/requirement-ids.js';
42
+ import { readErrorCode } from '../lib/error-envelope.js';
43
+ import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
44
+ import { CANARY_NAME, WITNESS_REF, WitnessHost, freshName, freshValue, hits, sha256Hex } from '../lib/run-secrets-witness.js';
45
+
46
+ const DOC = 'host-services.md §Run-supplied secrets (RFC 0229)';
47
+ const host = new WitnessHost(2);
48
+
49
+ interface Witnessed { runId: string; createText: string; status: string; matched: boolean | undefined; value: string; digestSupplied: boolean }
50
+ type Outcome = { runs: [Witnessed, Witnessed] } | { skip: 'inapplicable' | 'blocked'; reason: string } | { refused: { status: number; detail: string } };
51
+
52
+ /** Run A (`C`, `sha256(C)`) and run B (`D`, the digest of a different value), once per file. */
53
+ let pair: Promise<Outcome> | undefined;
54
+ function witnessedPair(): Promise<Outcome> {
55
+ pair ??= (async (): Promise<Outcome> => {
56
+ const g = await host.gate();
57
+ if (!g.ok) return { skip: g.kind, reason: g.reason };
58
+ const out: Witnessed[] = [];
59
+ for (const same of [true, false]) {
60
+ const value = freshValue();
61
+ const expected = same ? sha256Hex(value) : sha256Hex(freshValue());
62
+ const res = await host.create({ ref: WITNESS_REF, expectedSha256: expected }, [{ ref: WITNESS_REF, value }]);
63
+ if (res === null) return { skip: 'blocked', reason: 'POST /runs unreachable (fetch failed)' };
64
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
65
+ if (res.status !== 201 || typeof runId !== 'string') return { refused: { status: res.status, detail: `POST /runs with a well-formed runSecrets answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() } };
66
+ const snap = await host.waitTerminal(runId);
67
+ out.push({ runId, createText: res.text, status: String(snap?.['status']), matched: await host.matched(runId, snap), value, digestSupplied: same });
68
+ }
69
+ return { runs: [out[0]!, out[1]!] };
70
+ })();
71
+ return pair;
72
+ }
73
+
74
+ /** A run that must end in `witness`'s `node.failed`; returns the code, or a blocked reason. */
75
+ async function failedCode(inputs: { ref: string; expectedSha256: string }, runSecrets?: ReadonlyArray<{ ref: string; value: string }>): Promise<{ code: string | null; status: string } | { reason: string }> {
76
+ const res = await host.create(inputs, runSecrets);
77
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
78
+ const runId = (res.json as { runId?: unknown } | null)?.runId;
79
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
80
+ const snap = await host.waitTerminal(runId);
81
+ return { code: await host.nodeFailedCode(runId), status: String(snap?.['status']) };
82
+ }
83
+
84
+ describe('RFC 0229 — v2-secrets-run-witness (gated on secrets.runSecrets + openwop-secrets-run-witness)', () => {
85
+ it('a run-supplied secret resolves intact: matched true for its digest, false for another', async () => {
86
+ const o = await witnessedPair();
87
+ if ('skip' in o) return softSkip(o.skip, o.reason);
88
+ const id = 'openwop.requirement.secrets.run-witness-resolves';
89
+ if ('refused' in o) {
90
+ expect(o.refused.status, req(id, DOC, `a host advertising runSecrets MUST accept a well-formed runSecrets on createRun (${o.refused.detail})`)).toBe(201);
91
+ return blockedDespiteAssertions(`${o.refused.detail} with no runId — no run to witness`);
92
+ }
93
+ const [a, b] = o.runs;
94
+ expect(a.status, req(id, DOC, 'the witness run with a matching digest MUST complete')).toBe('completed');
95
+ expect(b.status, req(id, DOC, 'the witness run with a different digest MUST complete (a mismatch is an output, not a failure)')).toBe('completed');
96
+ if (a.matched === undefined || b.matched === undefined) return blockedDespiteAssertions('the runs completed but witness\'s `matched` output was on no readable surface (node.completed outputs or the snapshot)');
97
+ expect(a.matched, req(id, DOC, 'witness MUST output matched: true when expectedSha256 is the digest of the supplied value')).toBe(true);
98
+ expect(b.matched, req(id, DOC, 'witness MUST output matched: false when expectedSha256 is the digest of a different value')).toBe(false);
99
+ }, 90_000);
100
+
101
+ it('a run-supplied secret appears on no readable surface, in any encoding or as its digest', async () => {
102
+ const o = await witnessedPair();
103
+ if ('skip' in o) return softSkip(o.skip, o.reason);
104
+ if ('refused' in o) return softSkip('blocked', `${o.refused.detail} — no run to scan (row run-witness-resolves convicts the refusal)`);
105
+ const id = 'openwop.requirement.secrets.run-witness-redacted';
106
+ for (const r of o.runs) {
107
+ const s = await host.surfaces(r.runId, [r.createText]);
108
+ expect(hits(s.text, r.value, r.digestSupplied), req(id, DOC, `the supplied value MUST NOT appear on any surface (read: ${s.read.join(', ')}), raw, base64, hex, percent- or JSON-encoded, or as its SHA-256 where the suite did not send that digest itself`)).toEqual([]);
109
+ }
110
+ }, 90_000);
111
+
112
+ it('a run: ref resolves only within the run that supplied it, and the witness refuses any other ref', async () => {
113
+ const g = await host.gate();
114
+ if (!g.ok) return softSkip(g.kind, g.reason);
115
+ const id = 'openwop.requirement.secrets.run-witness-scope-bound';
116
+ const v = freshValue();
117
+ const cases: Array<{ what: string; inputs: { ref: string; expectedSha256: string }; runSecrets?: Array<{ ref: string; value: string }>; code: string }> = [
118
+ { what: `the canary name ${CANARY_NAME}`, inputs: { ref: CANARY_NAME, expectedSha256: sha256Hex(v) }, runSecrets: [{ ref: WITNESS_REF, value: v }], code: 'credential_forbidden' },
119
+ { what: 'a fresh name without run:', inputs: { ref: freshName(), expectedSha256: sha256Hex(v) }, runSecrets: [{ ref: WITNESS_REF, value: v }], code: 'credential_forbidden' },
120
+ { what: `${WITNESS_REF} with no runSecrets`, inputs: { ref: WITNESS_REF, expectedSha256: sha256Hex(v) }, code: 'credential_not_found' },
121
+ ];
122
+ const earlier = freshValue();
123
+ const first = await host.create({ ref: WITNESS_REF, expectedSha256: sha256Hex(earlier) }, [{ ref: WITNESS_REF, value: earlier }]);
124
+ const firstId = (first?.json as { runId?: unknown } | null)?.runId;
125
+ if (first === null || first.status !== 201 || typeof firstId !== 'string') return softSkip('blocked', `the earlier run's POST /runs answered ${first?.status ?? 'nothing'} ${readErrorCode(first?.json) ?? ''}`.trim());
126
+ await host.waitTerminal(firstId);
127
+ cases.push({ what: `an earlier run's ref (${WITNESS_REF}) while this run supplies only run:openwop-other`, inputs: { ref: WITNESS_REF, expectedSha256: sha256Hex(earlier) }, runSecrets: [{ ref: 'run:openwop-other', value: freshValue() }], code: 'credential_not_found' });
128
+ for (const c of cases) {
129
+ const r = await failedCode(c.inputs, c.runSecrets);
130
+ if ('reason' in r) return blockedDespiteAssertions(`${c.what}: ${r.reason}`);
131
+ expect(r.code, req(id, DOC, `${c.what}: witness MUST end in node.failed with ${c.code} (run status ${r.status})`)).toBe(c.code);
132
+ }
133
+ // §A.4: gated on replay advertising `branch` (RFC 0229 §F row 3).
134
+ if (g.branchFork) {
135
+ const fork = await host.fork(firstId);
136
+ const forkId = (fork?.json as { runId?: unknown } | null)?.runId;
137
+ // partial-witness-ok: the four scope legs above observed the requirement; this fork leg is conditional on a fork the host serves (the fork contract is not this row's)
138
+ if (fork === null || fork.status !== 201 || typeof forkId !== 'string') return softSkip('inapplicable', `replay advertises branch, but POST /runs/{runId}:fork answered ${fork?.status ?? 'nothing'} ${readErrorCode(fork?.json) ?? ''} — the fork leg did not run (runs.md §Fork owns that contract)`.trim());
139
+ await host.waitTerminal(forkId);
140
+ expect(await host.nodeFailedCode(forkId), req(id, DOC, 'a branch fork taken before witness MUST NOT inherit runSecrets: witness fails credential_not_found')).toBe('credential_not_found');
141
+ }
142
+ }, 180_000);
143
+
144
+ it('the idempotency request digest excludes runSecrets: a retry differing only there replays, and its value is discarded', async () => {
145
+ const g = await host.gate();
146
+ if (!g.ok) return softSkip(g.kind, g.reason);
147
+ const id = 'openwop.requirement.secrets.run-secrets-outside-request-digest';
148
+ const key = `openwop-witness-${randomUUID()}`;
149
+ const c = freshValue();
150
+ const c2 = freshValue();
151
+ const inputs = { ref: WITNESS_REF, expectedSha256: sha256Hex(c) };
152
+ const first = await host.create(inputs, [{ ref: WITNESS_REF, value: c }], key);
153
+ const runId = (first?.json as { runId?: unknown } | null)?.runId;
154
+ if (first === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
155
+ expect(first.status, req(id, DOC, 'a host advertising runSecrets MUST accept a well-formed runSecrets on createRun')).toBe(201);
156
+ if (typeof runId !== 'string') return blockedDespiteAssertions('the 201 carried no runId');
157
+ const retry = await host.create(inputs, [{ ref: WITNESS_REF, value: c2 }], key);
158
+ if (retry === null) return blockedDespiteAssertions('the same-key retry was unreachable (fetch failed)');
159
+ expect(readErrorCode(retry.json), req(id, `${DOC}; idempotency.md §Outcomes`, 'a same-key retry differing only in runSecrets MUST NOT be 409 idempotency_key_mismatch — the request digest MUST NOT cover runSecrets')).not.toBe('idempotency_key_mismatch');
160
+ expect(retry.status, req(id, DOC, 'the retry MUST be answered from cache with the original 201')).toBe(201);
161
+ expect((retry.json as { runId?: unknown } | null)?.runId, req(id, DOC, 'the retry MUST name the original runId')).toBe(runId);
162
+ expect(retry.headers.get('openwop-idempotent-replay'), req(id, 'idempotency.md §Outcomes', 'a response served from cache MUST carry OpenWOP-Idempotent-Replay: true')).toBe('true');
163
+ await host.waitTerminal(runId);
164
+ const s = await host.surfaces(runId, [first.text, retry.text]);
165
+ expect(hits(s.text, c2), req(id, DOC, `the retried value MUST be discarded unused: it appears on no surface of the run (read: ${s.read.join(', ')})`)).toEqual([]);
166
+ }, 90_000);
167
+ });
@@ -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
+ });