@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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `v2-workspace-scope-from-identity` (target major 2; gated on the `workspace`
|
|
3
|
+
* family and the `openwop-conformance-seams-v2` profile).
|
|
4
|
+
*
|
|
5
|
+
* `spec/v2/core/host-services.md` §`workspace`: a host advertising `workspace`
|
|
6
|
+
* "MUST derive the scope from the authenticated identity and MUST NOT return or
|
|
7
|
+
* disclose another scope's file; `404` MAY stand for `403` (invariant
|
|
8
|
+
* `workspace-cross-tenant-isolation`)". No protocol path serves workspace files,
|
|
9
|
+
* so the only observation surface is the EXISTING v2 seam
|
|
10
|
+
* `/conformance/seams/workspace/files[/{path}]` (`api/seams-v2.yaml`). This file
|
|
11
|
+
* adds no seam; it drives that one with two credentials.
|
|
12
|
+
*
|
|
13
|
+
* Until this file the rule had major-1 witnesses only (`workspace-cross-tenant-
|
|
14
|
+
* isolation`, `workspace-cross-tenant-isolation-blackbox`, both on `/v1/…`), so
|
|
15
|
+
* no v2 host was ever measured on it (docs/V2-WITNESS-COVERAGE.md risk #3).
|
|
16
|
+
*
|
|
17
|
+
* Legs:
|
|
18
|
+
* 1. tenant A writes a unique file; tenant B (OPENWOP_TEST_TENANT_B_API_KEY)
|
|
19
|
+
* reads it by path → 403/404 with no content, and B's list omits it.
|
|
20
|
+
* Positive control: A reads its own file back and lists it.
|
|
21
|
+
* 2. the same read and list by B, NAMING A's scope in the request
|
|
22
|
+
* (`tenantId` / `workspaceId` query parameters, the values read from
|
|
23
|
+
* `owner` of a run A just created). A host that takes the scope from the
|
|
24
|
+
* request instead of the credential serves A's file here.
|
|
25
|
+
*
|
|
26
|
+
* Dispositions: family or seams profile not advertised ⇒ `inapplicable`
|
|
27
|
+
* (lib/seams.ts: the host has not claimed the instrument). Advertised, but the
|
|
28
|
+
* seam answers 404/405/501, the second-tenant key is absent / identical /
|
|
29
|
+
* refused, or (leg 2) A's scope cannot be read from a run ⇒ `blocked` — the host
|
|
30
|
+
* took the obligation on and the suite could not measure it.
|
|
31
|
+
*/
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
35
|
+
import { loadEnv } from '../lib/env.js';
|
|
36
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
37
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
38
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
39
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
40
|
+
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
|
|
42
|
+
const DOC = 'spec/v2/core/host-services.md §workspace';
|
|
43
|
+
const FILES = `${SEAMS_PREFIX}/workspace/files`;
|
|
44
|
+
const NOOP = 'conformance-noop';
|
|
45
|
+
const ID_READ = 'openwop.requirement.0059.workspace-scope-from-identity';
|
|
46
|
+
const ID_NAMED = 'openwop.requirement.0059.workspace-scope-not-request-named';
|
|
47
|
+
|
|
48
|
+
type Gate = { ok: true; asB: { authenticated: false; headers: Record<string, string> } } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string; seam?: true };
|
|
49
|
+
|
|
50
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
51
|
+
try { return await fn(); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Family, profile, seam and second credential — decided before any assertion. */
|
|
55
|
+
async function gate(): Promise<Gate> {
|
|
56
|
+
const doc = await v2Discovery();
|
|
57
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
|
|
58
|
+
if (!(await gateFamily('workspace'))) return { ok: false, kind: 'inapplicable', reason: 'workspace family not advertised (gate recorded under openwop.family.workspace)' };
|
|
59
|
+
if (!seamsProfileAdvertised(doc)) return { ok: false, kind: 'inapplicable', reason: 'workspace files have no protocol path; the only observation surface is the seams profile (conformance.seamsProfile = openwop-conformance-seams-v2), which is not advertised' };
|
|
60
|
+
const probe = await http(() => driver.get(FILES));
|
|
61
|
+
if (probe === null) return { ok: false, kind: 'blocked', reason: `GET ${FILES} unreachable (fetch failed)` };
|
|
62
|
+
if ([404, 405, 501].includes(probe.status)) return { ok: false, kind: 'blocked', reason: `host advertises workspace and the seams profile but GET ${FILES} answered ${probe.status}`, seam: true };
|
|
63
|
+
const b = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
|
|
64
|
+
if (!b) return { ok: false, kind: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — no second-scope read can be measured' };
|
|
65
|
+
if (b === loadEnv().apiKey) return { ok: false, kind: 'blocked', reason: 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the leg needs a second tenant' };
|
|
66
|
+
const asB = { authenticated: false as const, headers: { Authorization: `Bearer ${b}` } };
|
|
67
|
+
const probeB = await http(() => driver.get(FILES, asB));
|
|
68
|
+
if (probeB === null || probeB.status === 401) return { ok: false, kind: 'blocked', reason: `OPENWOP_TEST_TENANT_B_API_KEY does not authenticate on this host (GET ${FILES} answered ${probeB?.status ?? 'nothing'})` };
|
|
69
|
+
return { ok: true, asB };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A's file, written through the seam; the caller deletes it. */
|
|
73
|
+
async function writeA(): Promise<{ path: string; secret: string } | { reason: string }> {
|
|
74
|
+
const path = `v2-wsi-${randomUUID()}.md`;
|
|
75
|
+
const secret = `V2-WSI-CANARY-${randomUUID()}`;
|
|
76
|
+
const put = await http(() => driver.put(`${FILES}/${path}`, { content: secret }));
|
|
77
|
+
if (put === null || put.status !== 200) return { reason: `tenant A could not write ${FILES}/${path} (answered ${put?.status ?? 'nothing'})` };
|
|
78
|
+
return { path, secret };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function paths(res: OpenWOPResponse | null): string[] {
|
|
82
|
+
const files = (res?.json as { files?: Array<{ path?: unknown }> } | undefined)?.files;
|
|
83
|
+
return Array.isArray(files) ? files.map((f) => String(f?.path)) : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A's own `{tenant, workspace}` as the host records it, from `owner` of a run A creates. */
|
|
87
|
+
async function scopeOf(as: Record<string, unknown> = {}): Promise<{ tenant: string; workspace?: string } | { reason: string }> {
|
|
88
|
+
if (!isFixtureAdvertised(NOOP)) return { reason: `${NOOP} is not advertised — the suite cannot learn a scope id to name` };
|
|
89
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP }, as));
|
|
90
|
+
const runId = (res?.json as { runId?: unknown } | undefined)?.runId;
|
|
91
|
+
if (res === null || res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {${NOOP}} answered ${res?.status ?? 'nothing'}` };
|
|
92
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, as));
|
|
93
|
+
const owner = (snap?.json as { owner?: { tenant?: unknown; workspace?: unknown } } | undefined)?.owner;
|
|
94
|
+
const tenant = typeof owner?.tenant === 'string' ? owner.tenant : runId.includes('/') ? runId.slice(0, runId.indexOf('/')) : null;
|
|
95
|
+
if (!tenant) return { reason: 'the run snapshot names no owner.tenant and the run id carries no tenant segment' };
|
|
96
|
+
return typeof owner?.workspace === 'string' ? { tenant, workspace: owner.workspace } : { tenant };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('v2 workspace scope comes from the authenticated identity (host-services.md §workspace)', () => {
|
|
100
|
+
it('a second tenant cannot read or list a file the first tenant wrote', async () => {
|
|
101
|
+
const g = await gate();
|
|
102
|
+
if (!g.ok && g.seam) return seamAbsent(g.reason);
|
|
103
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
104
|
+
const a = await writeA();
|
|
105
|
+
if ('reason' in a) return softSkip('blocked', a.reason);
|
|
106
|
+
try {
|
|
107
|
+
const own = await driver.get(`${FILES}/${a.path}`);
|
|
108
|
+
expect(own.status, req(ID_READ, DOC, 'positive control: the owning scope MUST read its own file (200)')).toBe(200);
|
|
109
|
+
expect((own.json as { content?: unknown } | undefined)?.content, req(ID_READ, DOC, 'positive control: the owner reads back the content it wrote')).toBe(a.secret);
|
|
110
|
+
expect(paths(await http(() => driver.get(FILES))), req(ID_READ, DOC, 'positive control: the owner\'s list names its own file')).toContain(a.path);
|
|
111
|
+
|
|
112
|
+
const cross = await driver.get(`${FILES}/${a.path}`, g.asB);
|
|
113
|
+
expect([403, 404], req(ID_READ, DOC, `another scope's read MUST NOT return the file — 404 MAY stand for 403 (got ${cross.status})`)).toContain(cross.status);
|
|
114
|
+
expect(cross.text.includes(a.secret), req(ID_READ, DOC, 'another scope\'s read MUST NOT disclose the file\'s content')).toBe(false);
|
|
115
|
+
const list = await http(() => driver.get(FILES, g.asB));
|
|
116
|
+
expect(list?.text.includes(a.secret) === true || paths(list).includes(a.path), req(ID_READ, DOC, 'another scope\'s list MUST NOT disclose the file')).toBe(false);
|
|
117
|
+
} finally {
|
|
118
|
+
await http(() => driver.delete(`${FILES}/${a.path}`));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('naming the first tenant\'s scope in the request does not widen the second tenant\'s scope', async () => {
|
|
123
|
+
const g = await gate();
|
|
124
|
+
if (!g.ok && g.seam) return seamAbsent(g.reason);
|
|
125
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
126
|
+
const scopeA = await scopeOf();
|
|
127
|
+
if ('reason' in scopeA) return softSkip('blocked', `A's scope id is needed to name it: ${scopeA.reason}`);
|
|
128
|
+
const scopeB = await scopeOf(g.asB);
|
|
129
|
+
if (!('reason' in scopeB) && scopeB.tenant === scopeA.tenant) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant');
|
|
130
|
+
const a = await writeA();
|
|
131
|
+
if ('reason' in a) return softSkip('blocked', a.reason);
|
|
132
|
+
try {
|
|
133
|
+
const q = new URLSearchParams({ tenantId: scopeA.tenant, tenant: scopeA.tenant, ...(scopeA.workspace ? { workspaceId: scopeA.workspace, workspace: scopeA.workspace } : {}) }).toString();
|
|
134
|
+
const own = await driver.get(`${FILES}/${a.path}`);
|
|
135
|
+
expect(own.status, req(ID_NAMED, DOC, 'positive control: the owning scope MUST read its own file (200)')).toBe(200);
|
|
136
|
+
|
|
137
|
+
const cross = await driver.get(`${FILES}/${a.path}?${q}`, g.asB);
|
|
138
|
+
expect([400, 403, 404], req(ID_NAMED, DOC, `the scope MUST come from the credential, not a request-named tenantId/workspaceId (got ${cross.status})`)).toContain(cross.status);
|
|
139
|
+
expect(cross.text.includes(a.secret), req(ID_NAMED, DOC, 'a request naming another scope MUST NOT disclose that scope\'s file')).toBe(false);
|
|
140
|
+
const list = await http(() => driver.get(`${FILES}?${q}`, g.asB));
|
|
141
|
+
expect(list?.text.includes(a.secret) === true || paths(list).includes(a.path), req(ID_NAMED, DOC, 'a list naming another scope MUST NOT disclose that scope\'s file')).toBe(false);
|
|
142
|
+
} finally {
|
|
143
|
+
await http(() => driver.delete(`${FILES}/${a.path}`));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|