@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.
- package/CHANGELOG.md +77 -0
- package/README.md +2 -2
- package/dist/cli.js +1 -1
- package/dist/lib/certification-bundle-verify.js +22 -1
- package/dist/lib/profiles.js +17 -2
- package/dist/lib/requirement-registry.js +15 -0
- package/dist/lib/scenario-disposition.js +60 -9
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-budget-tool-calls.json +81 -0
- package/fixtures/conformance-fs-probe.json +25 -0
- package/fixtures/conformance-queue-consume.json +27 -0
- package/fixtures/conformance-queue-publish.json +29 -0
- package/fixtures/conformance-safefetch-probe.json +23 -0
- package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
- package/fixtures/conformance-storage-probe.json +27 -0
- package/fixtures/conformance-tool-scope-probe.json +27 -0
- package/fixtures/openwop-secrets-run-witness.json +29 -0
- package/fixtures.md +113 -1
- package/package.json +2 -2
- package/requirement-aliases.json +72 -71
- package/requirements.json +1081 -43
- package/scenario-majors.json +45 -3
- package/schemas/CORPUS-STAMP.json +22 -22
- package/src/cli.ts +1 -1
- package/src/lib/backpressure-witness.ts +163 -0
- package/src/lib/budget-witness.ts +165 -0
- package/src/lib/certification-bundle-verify.ts +23 -2
- package/src/lib/driver.ts +61 -2
- package/src/lib/major-profile.ts +93 -0
- package/src/lib/memoryAttribution.ts +41 -6
- package/src/lib/polling.ts +2 -18
- package/src/lib/profiles.ts +26 -2
- package/src/lib/requirement-registry.ts +12 -0
- package/src/lib/run-secrets-witness.ts +240 -0
- package/src/lib/scenario-disposition.ts +54 -7
- package/src/lib/scratch-host.ts +130 -0
- package/src/lib/secret-scan.ts +141 -0
- package/src/lib/timeout-scale.ts +25 -0
- package/src/lib/triggerBridge.ts +69 -1
- package/src/scenarios/byok-roundtrip.test.ts +20 -4
- package/src/scenarios/runner-ledger.test.ts +2 -0
- package/src/scenarios/secrets-run-witness.test.ts +154 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
- package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
- package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
- package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
- package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
- package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
- package/src/scenarios/v2-production-backpressure.test.ts +74 -0
- package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
- package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
- package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
- package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
- package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
- package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
- 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
|
+
});
|