@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,118 @@
1
+ /**
2
+ * `spec/v2/core/host-services.md` §`queueBus` — "A tenant's consumer MUST NOT
3
+ * receive another tenant's messages, even on the same topic." (target major 2;
4
+ * gated on the `queueBus` family, the queue-probe fixture pair and a second
5
+ * tenant's credential).
6
+ *
7
+ * Before this file the rule was witnessed at major 1 only
8
+ * (`queue-cross-tenant-isolation`), through the v1 seam
9
+ * `POST /v1/host/sample/test/surface`, which names the tenant in the REQUEST
10
+ * BODY — so it never tested the thing that matters, that the tenant comes from
11
+ * the credential. Topics are named by pack authors, so two tenants running the
12
+ * same pack share topic names by default; a host that keys the physical queue
13
+ * by topic alone hands one tenant's messages to the other.
14
+ *
15
+ * No seam (`conformance.md` forbids growing the seam count). The leg drives
16
+ * `ctx.queueBus` through the normal run surface with two fixtures
17
+ * (`conformance/fixtures.md` §`conformance-queue-publish` /
18
+ * §`conformance-queue-consume`): tenant A (`OPENWOP_API_KEY`) publishes a fresh
19
+ * nonce on a fresh topic T; tenant B (`OPENWOP_TEST_TENANT_B_API_KEY`) consumes
20
+ * T and MUST NOT receive it; then A consumes T and MUST receive it — the
21
+ * positive control, without which "B got nothing" would also be what a host
22
+ * whose queue does nothing at all shows.
23
+ *
24
+ * Dispositions: `queueBus` not advertised, or either probe fixture not
25
+ * advertised ⇒ `inapplicable` (read with `familyAdvertised`, not `gateFamily`:
26
+ * no host advertises `queueBus` at v2 today, and a strict-mode family gate would
27
+ * demand a `family.queueBus` opt-out from every one of them). The family and fixtures advertised but no second
28
+ * tenant key, or the key binds the primary tenant ⇒ `blocked` (the only
29
+ * observation that convicts a leaking host did not run). A probe run that does
30
+ * not complete, or a consume that leaves `consumed` unset, fails: the fixture
31
+ * contract is the host's claim once it advertises the fixture.
32
+ *
33
+ * @see spec/v2/core/host-services.md §`queueBus`
34
+ * @see SECURITY/invariants.yaml `queue-cross-tenant-isolation`
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { randomBytes } from 'node:crypto';
39
+ import { driver, type OpenWOPResponse, type OpenWOPRequestInit } from '../lib/driver.js';
40
+ import { familyAdvertised, v2Discovery } from '../lib/v2.js';
41
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
42
+ import { readErrorCode } from '../lib/error-envelope.js';
43
+ import { softSkip } from '../lib/soft-skip.js';
44
+ import { req } from '../lib/requirement-ids.js';
45
+
46
+ const DOC = 'spec/v2/core/host-services.md §queueBus';
47
+ const ID = 'openwop.requirement.queueBus.cross-tenant-isolation';
48
+ const PUBLISH = 'conformance-queue-publish';
49
+ const CONSUME = 'conformance-queue-consume';
50
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
51
+
52
+ const enc = (id: string): string => encodeURIComponent(id);
53
+ const tenantOf = (runId: string): string => runId.slice(0, Math.max(0, runId.indexOf('/')));
54
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
55
+
56
+ /** The `consumed` array the consume node output, wherever the host surfaces it (node.completed, run.completed, the snapshot). */
57
+ function consumedOf(docs: readonly unknown[]): string[] | null {
58
+ let hit: string[] | null = null;
59
+ const walk = (v: unknown): void => {
60
+ if (hit !== null || v === null || typeof v !== 'object') return;
61
+ if (Array.isArray(v)) { v.forEach(walk); return; }
62
+ const c = (v as Record<string, unknown>)['consumed'];
63
+ if (Array.isArray(c)) { hit = c.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))); return; }
64
+ Object.values(v as Record<string, unknown>).forEach(walk);
65
+ };
66
+ docs.forEach(walk);
67
+ return hit;
68
+ }
69
+
70
+ interface Ran { readonly runId: string; readonly status: string; readonly docs: unknown[] }
71
+
72
+ async function run(workflowId: string, inputs: Record<string, unknown>, as: OpenWOPRequestInit, who: string): Promise<Ran | { reason: string }> {
73
+ const created = await http(() => driver.post('/runs', { workflowId, inputs }, as));
74
+ if (created === null) return { reason: `${who}: POST /runs unreachable (fetch failed)` };
75
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
76
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `${who}: POST /runs {workflowId: ${workflowId}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the fixture is advertised but did not start`.trim() };
77
+ const deadline = Date.now() + 20_000;
78
+ let snap: OpenWOPResponse | null = null;
79
+ let status = '';
80
+ while (Date.now() < deadline) {
81
+ snap = await http(() => driver.get(`/runs/${enc(runId)}`, as));
82
+ status = snap?.status === 200 ? String((snap.json as { status?: unknown } | undefined)?.status ?? '') : status;
83
+ if (TERMINAL.has(status)) break;
84
+ await new Promise((r) => setTimeout(r, 200));
85
+ }
86
+ if (!TERMINAL.has(status)) return { reason: `${who}: the ${workflowId} run ${runId} did not settle within 20 s (last status: ${status || 'unreadable'})` };
87
+ const poll = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`, as));
88
+ return { runId, status, docs: [snap?.json, poll?.status === 200 ? poll.json : null] };
89
+ }
90
+
91
+ describe('v2 queue-cross-tenant-isolation (host-services.md §queueBus)', () => {
92
+ it('a message tenant A publishes on topic T is never consumed by tenant B on T, and A\'s own consumer receives it', async () => {
93
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 under OpenWOP-Version: 2.0');
94
+ if (!(await familyAdvertised('queueBus'))) return softSkip('inapplicable', 'queueBus family not advertised — no obligation');
95
+ for (const f of [PUBLISH, CONSUME]) if (!isFixtureAdvertised(f)) return softSkip('inapplicable', `fixture ${f} is not advertised — the host does not claim the queue-probe fixture pair`);
96
+ const keyB = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
97
+ if (!keyB) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY is not set — the second-tenant consume, the only observation a leaking host fails, cannot run');
98
+ const asA: OpenWOPRequestInit = {};
99
+ const asB: OpenWOPRequestInit = { authenticated: false, headers: { Authorization: `Bearer ${keyB}` } };
100
+
101
+ const topic = `openwop.conformance.xtenant.${randomBytes(6).toString('hex')}`;
102
+ const nonce = `openwop-conformance-queue-${randomBytes(12).toString('hex')}`;
103
+
104
+ const pub = await run(PUBLISH, { topic, message: nonce }, asA, 'tenant A publish'); if ('reason' in pub) return softSkip('blocked', pub.reason);
105
+ expect(pub.status, req(ID, `conformance/fixtures.md §${PUBLISH}`, `tenant A's publish run MUST complete (got ${pub.status})`)).toBe('completed');
106
+
107
+ const b = await run(CONSUME, { topic }, asB, 'tenant B consume'); if ('reason' in b) return softSkip('blocked', b.reason);
108
+ if (tenantOf(b.runId) === tenantOf(pub.runId)) return softSkip('blocked', `OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY (${tenantOf(pub.runId)}) — the leg needs a second tenant`);
109
+ expect(b.status, req(ID, `conformance/fixtures.md §${CONSUME}`, `tenant B's consume run MUST complete (got ${b.status})`)).toBe('completed');
110
+ const gotB = consumedOf(b.docs);
111
+ expect(Array.isArray(gotB), req(ID, `conformance/fixtures.md §${CONSUME}`, 'the consume node MUST output `consumed` as an array ([] when nothing arrived), never leave it unset — an unset output would pass the isolation check vacuously')).toBe(true);
112
+ expect(gotB ?? [], req(ID, DOC, `a tenant's consumer MUST NOT receive another tenant's messages, even on the same topic — tenant B (${tenantOf(b.runId)}) consumed tenant A's (${tenantOf(pub.runId)}) message on ${topic}`)).not.toContain(nonce);
113
+
114
+ const a = await run(CONSUME, { topic }, asA, 'tenant A consume'); if ('reason' in a) return softSkip('blocked', a.reason);
115
+ expect(a.status, req(ID, `conformance/fixtures.md §${CONSUME}`, `tenant A's consume run MUST complete (got ${a.status})`)).toBe('completed');
116
+ expect(consumedOf(a.docs) ?? [], req(ID, `conformance/fixtures.md §${CONSUME}`, `positive control: tenant A's own consumer on ${topic} MUST receive the message A published — without it "tenant B received nothing" is also what a queue that delivers nothing shows`)).toContain(nonce);
117
+ }, 90_000);
118
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `spec/v2/core/host-services.md` §`httpClient` — the SSRF guard REFUSES, on
3
+ * the resolved address (target major 2; gated on `httpClient.safeFetch` + the
4
+ * `conformance-safefetch-probe` fixture).
5
+ *
6
+ * The rule: "Before connecting it MUST resolve the target, reject loopback,
7
+ * RFC 1918, link-local and cloud-metadata addresses, and pin the resolved
8
+ * address for the connection (invariant `http-client-ssrf-guard`). A refused
9
+ * target is `egress_denied`, `reason: ssrf-blocked`; an unreachable one,
10
+ * `upstream_unavailable`." And for `safeFetch`: it "MUST apply that guard".
11
+ *
12
+ * Until this file the guard had no refusal witness at any major that a
13
+ * production host could run: `http-client-ssrf` (major 1) asserts only the
14
+ * `ssrfGuard: true` advertisement, and `safefetch-behavior` drives a v1 seam.
15
+ *
16
+ * No seam. Each probe runs the `conformance-safefetch-probe` fixture through
17
+ * `POST /runs`; its node calls the host's own `ctx.http.safeFetch(url)` and
18
+ * passes a rejection through as `node.failed`, code and details unchanged
19
+ * (conformance/fixtures.md §"The safeFetch probe fixture"). One `it` and one
20
+ * requirement id per address class the sentence names, so a failure names its
21
+ * class:
22
+ *
23
+ * loopback 127.0.0.1, [::1]
24
+ * RFC 1918 10.0.0.1, 172.16.0.1, 192.168.0.1
25
+ * link-local / 169.254.169.254 (the cloud-metadata address), 169.254.0.1,
26
+ * metadata [fe80::1]
27
+ * spellings the same loopback / metadata / RFC 1918 addresses written as
28
+ * a URL parser or getaddrinfo also reads them: decimal
29
+ * (2130706433), octal (0177.0.0.1), short (127.1), and
30
+ * IPv4-mapped IPv6 in hex ([::ffff:7f00:1], [::ffff:a9fe:a9fe],
31
+ * [::ffff:a00:1]). They ARE those addresses, so the same
32
+ * obligation applies; nothing beyond the sentence is asserted.
33
+ * resolved `localhost`, a NAME the host must resolve before it can
34
+ * name judge it — a guard that checks only the literal string lets
35
+ * it connect (the sabotage the coverage report names).
36
+ *
37
+ * Every probe MUST end `failed` with `egress_denied` and `details.reason:
38
+ * ssrf-blocked`. The loopback-reaching probes target a listener the suite
39
+ * opens on its own loopback, with a per-run nonce in the path; when the host
40
+ * runs on the suite's machine, a guard that connects first (or not at all) is
41
+ * seen arriving there, and the "before connecting" half is asserted directly.
42
+ * Against a remote host that listener is unreachable by construction and the
43
+ * refusal code alone carries the leg.
44
+ *
45
+ * Not asserted, deliberately: a redirect to a private address (a public
46
+ * redirector would need a tunnel, and the sentence does not say whether
47
+ * safeFetch follows redirects), `0.0.0.0` and IPv6 ULA (not in the
48
+ * sentence's list for httpClient), and names that resolve only on some
49
+ * clouds (`metadata.google.internal` is unresolvable off GCP, where a correct
50
+ * host answers `upstream_unavailable`).
51
+ *
52
+ * Dispositions: v2 root unreachable ⇒ `blocked`. `httpClient` absent, or
53
+ * present without `safeFetch` ⇒ `inapplicable` (pack code has no egress to
54
+ * guard). Fixture not advertised ⇒ `inapplicable`: safeFetch has no protocol
55
+ * path, so the fixture is the only observation, and the host does not claim
56
+ * it (the `v2-memory-cross-tenant-isolation` / `v2-queue-cross-tenant-isolation`
57
+ * precedent). A fixture run that cannot be created or does not settle ⇒
58
+ * `blocked`.
59
+ *
60
+ * Sabotage (patched local v2 reference host): a guard that checks only a
61
+ * literal IP fails the resolved-name leg; a regex deny list on the hostname
62
+ * fails the spellings and link-local legs; no guard fails every leg; a guard
63
+ * that connects first and refuses after fails the loopback leg on the
64
+ * listener; a refusal coded `forbidden` fails every leg on the code.
65
+ *
66
+ * @see spec/v2/core/host-services.md §httpClient
67
+ * @see SECURITY/invariants.yaml id: http-client-ssrf-guard
68
+ * @see conformance/fixtures.md §"The safeFetch probe fixture"
69
+ */
70
+
71
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
72
+ import { randomUUID } from 'node:crypto';
73
+ import { createServer, type Server } from 'node:http';
74
+ import type { AddressInfo } from 'node:net';
75
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
76
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
77
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
78
+ import { readErrorCode } from '../lib/error-envelope.js';
79
+ import { softSkip } from '../lib/soft-skip.js';
80
+ import { req } from '../lib/requirement-ids.js';
81
+
82
+ const FIXTURE = 'conformance-safefetch-probe';
83
+ const NODE_ID = 'safefetch-probe';
84
+ const DOC = 'spec/v2/core/host-services.md §httpClient';
85
+ const ID_LOOPBACK = 'openwop.requirement.httpClient.ssrf-loopback-refused';
86
+ const ID_PRIVATE = 'openwop.requirement.httpClient.ssrf-rfc1918-refused';
87
+ const ID_LINK_LOCAL = 'openwop.requirement.httpClient.ssrf-link-local-metadata-refused';
88
+ const ID_SPELLING = 'openwop.requirement.httpClient.ssrf-address-spellings-refused';
89
+ const ID_RESOLVED = 'openwop.requirement.httpClient.ssrf-resolved-name-refused';
90
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
91
+ const NONCE = randomUUID().replace(/-/g, '');
92
+
93
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
94
+ const enc = (id: string): string => encodeURIComponent(id);
95
+
96
+ // ── The suite's own loopback listener: any request carrying NONCE is a connection the guard let through. ──
97
+ let listener: Server | null = null;
98
+ let port = 0;
99
+ const arrivals: string[] = [];
100
+ beforeAll(async () => {
101
+ const s = createServer((rq, rs) => { arrivals.push(`${rq.method ?? ''} ${rq.url ?? ''}`); rs.writeHead(200, { 'content-type': 'text/plain' }); rs.end('openwop-ssrf-probe'); });
102
+ // `::` with dual-stack takes 127.0.0.1 and ::1 alike; fall back to IPv4 loopback where IPv6 is off.
103
+ const bound = await new Promise<boolean>((res) => { s.once('error', () => res(false)); s.listen({ host: '::', port: 0, ipv6Only: false }, () => res(true)); });
104
+ if (!bound) await new Promise<void>((res, rej) => { s.removeAllListeners('error'); s.once('error', rej); s.listen({ host: '127.0.0.1', port: 0 }, () => res()); });
105
+ listener = s; port = (s.address() as AddressInfo).port;
106
+ });
107
+ afterAll(async () => { await new Promise<void>((res) => (listener ? listener.close(() => res()) : res())); });
108
+ const reached = (tag: string): string[] => arrivals.filter((a) => a.includes(`${NONCE}/${tag}/`));
109
+ const path = (tag: string): string => `/openwop-ssrf-probe/${NONCE}/${tag}`;
110
+
111
+ interface Outcome { readonly url: string; readonly status: string; readonly code: string | null; readonly detailReason: unknown }
112
+
113
+ /** Run the probe fixture for one URL and read the node's terminal event. */
114
+ async function probe(url: string): Promise<Outcome | { reason: string }> {
115
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: { url } }));
116
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
117
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
118
+ 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() };
119
+ const t0 = Date.now(); let status = '';
120
+ while (Date.now() - t0 < 60_000) {
121
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
122
+ status = String((snap?.json as { status?: unknown } | null)?.status ?? '');
123
+ if (TERMINAL.has(status)) break;
124
+ await new Promise((r) => setTimeout(r, 200));
125
+ }
126
+ if (!TERMINAL.has(status)) return { reason: `the ${FIXTURE} run for ${url} did not reach a terminal status within 60 s (last: ${status || 'unreadable'})` };
127
+ const ev = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
128
+ const events = (ev?.json as { events?: unknown } | null)?.events;
129
+ if (ev?.status !== 200 || !Array.isArray(events)) return { reason: `GET /runs/{runId}/events/poll answered ${ev?.status ?? 'nothing'}` };
130
+ const failed = (events as Array<{ type?: unknown; payload?: Record<string, unknown> }>).find((e) => e.type === 'node.failed' && e.payload?.['nodeId'] === NODE_ID)?.payload;
131
+ const error = (failed?.['error'] ?? null) as { code?: unknown; details?: { reason?: unknown } } | null;
132
+ return { url, status, code: typeof error?.code === 'string' ? error.code : null, detailReason: error?.details?.reason };
133
+ }
134
+
135
+ /** The gate, then every probe of one class; or the recorded reason the leg cannot run. */
136
+ async function leg(urls: readonly string[]): Promise<Outcome[] | { skip: ['inapplicable' | 'blocked', string] }> {
137
+ if (!(await v2Discovery().catch(() => null))) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'] };
138
+ const hc = await familyAdvertised('httpClient');
139
+ if (!hc) return { skip: ['inapplicable', 'httpClient is not advertised in the v2 discovery root — the host has no outbound client to guard'] };
140
+ if (!hc['safeFetch'] || typeof hc['safeFetch'] !== 'object') return { skip: ['inapplicable', 'httpClient.safeFetch is not advertised — pack code has no ctx.http.safeFetch, so there is no pack-driven egress to observe'] };
141
+ if (!isFixtureAdvertised(FIXTURE)) return { skip: ['inapplicable', `fixture ${FIXTURE} is not advertised — safeFetch has no protocol path, and the host does not claim the fixture that observes it`] };
142
+ if (listener === null) return { skip: ['blocked', 'the suite could not open its loopback listener'] };
143
+ const out = await Promise.all(urls.map((u) => probe(u)));
144
+ const bad = out.find((o): o is { reason: string } => !('url' in o));
145
+ if (bad) return { skip: ['blocked', bad.reason] };
146
+ return out as Outcome[];
147
+ }
148
+
149
+ const describeOutcome = (o: Outcome): string => `${o.url} → ${o.status}${o.code ? ` ${o.code}` : ''}${o.detailReason !== undefined ? ` (reason ${JSON.stringify(o.detailReason)})` : ''}`;
150
+
151
+ function assertRefused(id: string, cls: string, outcomes: readonly Outcome[]): void {
152
+ const notRefused = outcomes.filter((o) => o.status !== 'failed' || o.code !== 'egress_denied');
153
+ expect(notRefused.map(describeOutcome), req(id, DOC, `safeFetch MUST refuse a ${cls} target as egress_denied — these were not`)).toEqual([]);
154
+ const wrongReason = outcomes.filter((o) => o.detailReason !== 'ssrf-blocked');
155
+ expect(wrongReason.map(describeOutcome), req(id, DOC, `a refused ${cls} target carries details.reason: ssrf-blocked`)).toEqual([]);
156
+ }
157
+
158
+ describe('v2 safeFetch SSRF guard (host-services.md §httpClient)', () => {
159
+ it('a loopback target is refused egress_denied, ssrf-blocked, before any connection', async () => {
160
+ const r = await leg([`http://127.0.0.1:${port}${path('loopback')}/v4`, `http://[::1]:${port}${path('loopback')}/v6`]);
161
+ if ('skip' in r) return softSkip(...r.skip);
162
+ assertRefused(ID_LOOPBACK, 'loopback', r);
163
+ expect(reached('loopback'), req(ID_LOOPBACK, DOC, 'the guard MUST refuse BEFORE connecting — these probe requests reached the suite\'s loopback listener')).toEqual([]);
164
+ }, 120_000);
165
+
166
+ it('an RFC 1918 target is refused egress_denied, ssrf-blocked', async () => {
167
+ const r = await leg(['http://10.0.0.1/openwop-ssrf-probe', 'http://172.16.0.1/openwop-ssrf-probe', 'http://192.168.0.1/openwop-ssrf-probe']);
168
+ if ('skip' in r) return softSkip(...r.skip);
169
+ assertRefused(ID_PRIVATE, 'RFC 1918', r);
170
+ }, 120_000);
171
+
172
+ it('a link-local or cloud-metadata target is refused egress_denied, ssrf-blocked', async () => {
173
+ const r = await leg(['http://169.254.169.254/latest/meta-data/', 'http://169.254.0.1/openwop-ssrf-probe', 'http://[fe80::1]/openwop-ssrf-probe']);
174
+ if ('skip' in r) return softSkip(...r.skip);
175
+ assertRefused(ID_LINK_LOCAL, 'link-local / cloud-metadata', r);
176
+ }, 120_000);
177
+
178
+ it('the same addresses in decimal, octal, short and IPv4-mapped IPv6 spellings are refused', async () => {
179
+ const r = await leg([
180
+ `http://2130706433:${port}${path('spelling')}/decimal`,
181
+ `http://0177.0.0.1:${port}${path('spelling')}/octal`,
182
+ `http://127.1:${port}${path('spelling')}/short`,
183
+ `http://[::ffff:7f00:1]:${port}${path('spelling')}/mapped`,
184
+ 'http://[::ffff:a9fe:a9fe]/latest/meta-data/',
185
+ 'http://[::ffff:a00:1]/openwop-ssrf-probe',
186
+ ]);
187
+ if ('skip' in r) return softSkip(...r.skip);
188
+ assertRefused(ID_SPELLING, 'loopback / metadata / RFC 1918 (alternate spelling)', r);
189
+ expect(reached('spelling'), req(ID_SPELLING, DOC, 'the guard MUST refuse BEFORE connecting — these probe requests reached the suite\'s loopback listener')).toEqual([]);
190
+ }, 120_000);
191
+
192
+ it('a name that resolves to loopback is refused on its resolved address', async () => {
193
+ const r = await leg([`http://localhost:${port}${path('resolved')}/name`]);
194
+ if ('skip' in r) return softSkip(...r.skip);
195
+ assertRefused(ID_RESOLVED, 'name resolving to loopback', r);
196
+ expect(reached('resolved'), req(ID_RESOLVED, DOC, 'the host MUST resolve the target and reject a loopback address before connecting — the probe reached the suite\'s loopback listener')).toEqual([]);
197
+ }, 120_000);
198
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `spec/v2/core/host-services.md` §`secrets` — a secret the host resolves from
3
+ * its OWN store never reaches a readable surface (target major 2; gated on the
4
+ * `secrets` family and the `openwop-smoke-byok-roundtrip` fixture).
5
+ *
6
+ * "Raw key material MUST NOT appear in any event, log, trace, prompt, error,
7
+ * export or screenshot" · "MUST keep the plaintext out of events, spans,
8
+ * logs, snapshots and replay state. A replay re-resolves it"
9
+ *
10
+ * Before this file the rule was witnessed at major 1 only, and only on the
11
+ * envelope (`envelope-reasoning-secret-redaction`) and OTel
12
+ * (`secret-leakage-otel-attribute`) paths. `byok-roundtrip` runs the same
13
+ * fixture but searches for four key NAMES, never the value — any leak under
14
+ * another key passes it.
15
+ *
16
+ * What this adds, over the fixture the host already advertises (its node
17
+ * resolves the store canary `openwop-conformance-canary-secret` and outputs only
18
+ * `{secretSha256, secretLength}`):
19
+ *
20
+ * 1. `resolved-secret-absent` — after a completed run, the canary is on none
21
+ * of: the createRun answer, the snapshot, the poll log (`streamMode=debug`),
22
+ * the SSE stream (`streamMode=debug`), the run's `listRuns` page (when
23
+ * `runList` is advertised), or the error envelope a cancel of the terminal
24
+ * run answers.
25
+ * 2. `resolved-secret-absent-on-failure` — the same after a run that resolves
26
+ * the canary and then FAILS (`conformance-secret-resolve-then-fail`), where
27
+ * the snapshot `error`, `node.failed` and `run.failed` join the surfaces.
28
+ * A host that folds the node's resolved context into an error is caught here.
29
+ * 3. `resolved-secret-absent-in-fork` — a replay fork taken before the resolve
30
+ * node re-resolves the canary (§`secrets`: "A replay re-resolves it"); the
31
+ * fork's answer, snapshot and log carry it nowhere either (gated on `replay`).
32
+ *
33
+ * How the suite recognises the canary without being told it: every window of
34
+ * `secretLength` bytes on every surface (and inside every base64 / hex /
35
+ * percent-decoded token) is hashed and compared with the surfaced
36
+ * `secretSha256` (`lib/secret-scan.ts`). When the operator also supplies the
37
+ * plaintext (`OPENWOP_CANARY_SECRET_VALUE`, as `secret-leakage-otel-attribute`
38
+ * already reads) it is searched for directly in every common encoding.
39
+ *
40
+ * Not duplicated: RFC 0229 (`runSecrets`, `core.secret.witness`,
41
+ * `secrets.run-witness-*`) witnesses a value the SUITE supplies with the run.
42
+ * This file witnesses a secret the host resolves from its own store — the BYOK
43
+ * path the §`secrets` rule exists for. Its digest is legitimately on the
44
+ * surfaces (the byok fixture's contract emits it), so it is not a finding here.
45
+ *
46
+ * Dispositions: `secrets` not advertised, or the leg's fixture not advertised
47
+ * ⇒ `inapplicable`, read with `familyAdvertised` rather than `gateFamily` so
48
+ * strict mode does not demand a `family.secrets` opt-out from a host that never
49
+ * claimed it (a production host is right to withhold the canary fixture —
50
+ * RFC 0229 §Motivation). No detector (the digest not surfaced — a MAY — and no
51
+ * operator value) ⇒ `inapplicable`: nothing on this host can be recognised as
52
+ * the canary. An advertised fixture that cannot be run ⇒ `blocked`.
53
+ *
54
+ * @see spec/v2/core/host-services.md §`secrets`
55
+ * @see conformance/fixtures.md §`openwop-smoke-byok-roundtrip`, §`conformance-secret-resolve-then-fail`
56
+ */
57
+
58
+ import { describe, it, expect } from 'vitest';
59
+ import { createHash } from 'node:crypto';
60
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
61
+ import { subscribe } from '../lib/sse.js';
62
+ import { familyAdvertised, v2Discovery } from '../lib/v2.js';
63
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
64
+ import { readErrorCode } from '../lib/error-envelope.js';
65
+ import { softSkip } from '../lib/soft-skip.js';
66
+ import { req } from '../lib/requirement-ids.js';
67
+ import { digestDetector, findSecretDigest, leaks, valueDetector, type SecretDetector, type Surface } from '../lib/secret-scan.js';
68
+
69
+ const DOC = 'spec/v2/core/host-services.md §secrets';
70
+ const ID_ABSENT = 'openwop.requirement.secrets.resolved-secret-absent';
71
+ const ID_FAILURE = 'openwop.requirement.secrets.resolved-secret-absent-on-failure';
72
+ const ID_FORK = 'openwop.requirement.secrets.resolved-secret-absent-in-fork';
73
+ const BYOK = 'openwop-smoke-byok-roundtrip';
74
+ const THEN_FAIL = 'conformance-secret-resolve-then-fail';
75
+ const RESOLVE_NODE = 'resolve-secret';
76
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
77
+ const V2 = { 'OpenWOP-Version': '2.0' };
78
+
79
+ const enc = (id: string): string => encodeURIComponent(id);
80
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
81
+ function parse(text: string): unknown { try { return JSON.parse(text); } catch { return text; } }
82
+
83
+ async function gate(fixture: string): Promise<string | null> {
84
+ if (!(await v2Discovery())) return 'blocked:v2 discovery unreachable — /.well-known/openwop did not answer 200 under OpenWOP-Version: 2.0';
85
+ if (!(await familyAdvertised('secrets'))) return 'inapplicable:secrets family not advertised — no obligation';
86
+ if (!isFixtureAdvertised(fixture)) return `inapplicable:fixture ${fixture} is not advertised — the host does not claim the store-canary fixture (a production host may withhold it; RFC 0229's run witness is that host's path)`;
87
+ return null;
88
+ }
89
+ function skip(why: string): undefined {
90
+ const i = why.indexOf(':');
91
+ return softSkip(why.slice(0, i) as 'blocked' | 'inapplicable', why.slice(i + 1));
92
+ }
93
+
94
+ interface Settled { readonly runId: string; readonly status: string; readonly surfaces: Surface[]; readonly docs: unknown[]; readonly events: Array<{ sequence?: unknown; type?: unknown; nodeId?: unknown }> }
95
+
96
+ /** Settle a run and read every readable surface of it. */
97
+ async function readRun(runId: string, created: OpenWOPResponse, label: string): Promise<Settled | { reason: string }> {
98
+ const deadline = Date.now() + 20_000;
99
+ let snap: OpenWOPResponse | null = null;
100
+ let status = '';
101
+ while (Date.now() < deadline) {
102
+ snap = await http(() => driver.get(`/runs/${enc(runId)}`));
103
+ status = snap?.status === 200 ? String((snap.json as { status?: unknown } | undefined)?.status ?? '') : status;
104
+ if (TERMINAL.has(status)) break;
105
+ await new Promise((r) => setTimeout(r, 200));
106
+ }
107
+ if (snap === null || snap.status !== 200 || !TERMINAL.has(status)) return { reason: `the ${label} run ${runId} did not settle within 20 s (last status: ${status || 'unreadable'})` };
108
+ const poll = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1&streamMode=debug`));
109
+ if (poll === null || poll.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'no response'} for the ${label} run` };
110
+ const events = ((poll.json as { events?: unknown } | undefined)?.events ?? []) as Settled['events'];
111
+ const surfaces: Surface[] = [
112
+ { name: `${label}: createRun answer`, text: created.text },
113
+ { name: `${label}: GET /runs/{runId} snapshot`, text: snap.text },
114
+ { name: `${label}: GET /runs/{runId}/events/poll?streamMode=debug`, text: poll.text },
115
+ ];
116
+ const docs: unknown[] = [created.json, snap.json, poll.json];
117
+ const sse = await subscribe(`/runs/${enc(runId)}/events?streamMode=debug`, { timeoutMs: 8_000, extraHeaders: V2 }).catch(() => null);
118
+ if (sse !== null && sse.status === 200) {
119
+ const frames = sse.events.map((f) => parse(f.data));
120
+ surfaces.push({ name: `${label}: GET /runs/{runId}/events (SSE, streamMode=debug)`, text: JSON.stringify(frames) });
121
+ docs.push(frames);
122
+ }
123
+ // An error envelope the run can produce on demand: cancelling a terminal run.
124
+ const cancel = await http(() => driver.post(`/runs/${enc(runId)}/cancel`, {}));
125
+ if (cancel !== null) surfaces.push({ name: `${label}: POST /runs/{runId}/cancel on the terminal run (${cancel.status} ${readErrorCode(cancel.json) ?? ''})`.replace(' )', ')'), text: cancel.text });
126
+ if (await familyAdvertised('runList')) {
127
+ const list = await http(() => driver.get(`/runs?workflowId=${enc(String((snap!.json as { workflowId?: unknown }).workflowId ?? ''))}&limit=10`));
128
+ if (list !== null && list.status === 200) surfaces.push({ name: `${label}: GET /runs (listRuns page)`, text: list.text });
129
+ }
130
+ return { runId, status, surfaces, docs, events };
131
+ }
132
+
133
+ /** The detectors this host lets the suite build: the operator's value, the surfaced digest, or both. */
134
+ function detectorsFor(docs: readonly unknown[]): SecretDetector[] {
135
+ const out: SecretDetector[] = [];
136
+ const operator = process.env['OPENWOP_CANARY_SECRET_VALUE'];
137
+ const digest = findSecretDigest(docs);
138
+ if (operator && operator.length > 0) {
139
+ // An operator value that is not the canary this host resolved would make the value search vacuous.
140
+ if (digest === null || createHash('sha256').update(operator, 'utf8').digest('hex') === digest.sha256.toLowerCase()) out.push(valueDetector(operator));
141
+ }
142
+ if (digest !== null) out.push(digestDetector(digest.sha256, digest.length));
143
+ return out;
144
+ }
145
+
146
+ async function create(workflowId: string): Promise<{ runId: string; res: OpenWOPResponse } | { reason: string }> {
147
+ const res = await http(() => driver.post('/runs', { workflowId }));
148
+ if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
149
+ const runId = (res.json as { runId?: unknown } | undefined)?.runId;
150
+ if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${workflowId}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the fixture is advertised but did not start (canary not provisioned?)`.trim() };
151
+ return { runId, res };
152
+ }
153
+
154
+ const NO_DETECTOR = 'the run surfaced no {secretSha256, secretLength} (surfacing node outputs is a MAY) and OPENWOP_CANARY_SECRET_VALUE is unset — nothing on this host can be recognised as the canary';
155
+
156
+ /** Shared by the three legs: the source run of the byok fixture, read once. */
157
+ let source: Promise<Settled | { reason: string }> | null = null;
158
+ function byokRun(): Promise<Settled | { reason: string }> {
159
+ source ??= (async () => {
160
+ const c = await create(BYOK);
161
+ if ('reason' in c) return c;
162
+ return readRun(c.runId, c.res, 'byok');
163
+ })();
164
+ return source;
165
+ }
166
+
167
+ describe('v2 secret-canary-absent (host-services.md §secrets)', () => {
168
+ it('a secret resolved from the host\'s store appears on no readable surface of the run', async () => {
169
+ const why = await gate(BYOK); if (why) return skip(why);
170
+ const run = await byokRun(); if ('reason' in run) return softSkip('blocked', run.reason);
171
+ const detectors = detectorsFor(run.docs);
172
+ if (detectors.length === 0) return softSkip('inapplicable', NO_DETECTOR);
173
+ expect(run.status, req(ID_ABSENT, 'conformance/fixtures.md §openwop-smoke-byok-roundtrip', `the byok fixture run MUST complete when the host advertises it (got ${run.status})`)).toBe('completed');
174
+ const found = leaks(run.surfaces, detectors);
175
+ expect(found, req(ID_ABSENT, DOC, `raw key material MUST NOT appear in any event, snapshot or error — the canary was found on ${found.length} of ${run.surfaces.length} surface(s): ${found.join(' | ')}`)).toEqual([]);
176
+ }, 60_000);
177
+
178
+ it('a secret resolved by a run that then fails appears on no readable surface, the error included', async () => {
179
+ const why = await gate(THEN_FAIL); if (why) return skip(why);
180
+ const c = await create(THEN_FAIL); if ('reason' in c) return softSkip('blocked', c.reason);
181
+ const run = await readRun(c.runId, c.res, 'resolve-then-fail'); if ('reason' in run) return softSkip('blocked', run.reason);
182
+ // The failing run's own digest, else the completed byok run's (the same canary) when that fixture is advertised too.
183
+ const extra = isFixtureAdvertised(BYOK) ? await byokRun() : null;
184
+ const detectors = detectorsFor([...run.docs, ...(extra !== null && !('reason' in extra) ? extra.docs : [])]);
185
+ if (detectors.length === 0) return softSkip('inapplicable', NO_DETECTOR);
186
+ expect(run.status, req(ID_FAILURE, `conformance/fixtures.md §${THEN_FAIL}`, `the resolve-then-fail run MUST end failed (got ${run.status})`)).toBe('failed');
187
+ const found = leaks(run.surfaces, detectors);
188
+ expect(found, req(ID_FAILURE, DOC, `raw key material MUST NOT appear in any error or event of a run that resolved it and failed — the canary was found on: ${found.join(' | ')}`)).toEqual([]);
189
+ }, 90_000);
190
+
191
+ it('a replay fork re-resolves the secret and still carries it on no readable surface', async () => {
192
+ const why = await gate(BYOK); if (why) return skip(why);
193
+ const replay = await familyAdvertised('replay');
194
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised — no fork, no replay state');
195
+ const modes = Array.isArray(replay['modes']) ? (replay['modes'] as unknown[]).map(String) : [];
196
+ const mode = modes.includes('replay') ? 'replay' : modes.includes('branch') ? 'branch' : null;
197
+ if (mode === null) return softSkip('inapplicable', `replay.modes names neither replay nor branch (${JSON.stringify(modes)})`);
198
+ const run = await byokRun(); if ('reason' in run) return softSkip('blocked', run.reason);
199
+ const started = run.events.find((e) => e.type === 'node.started' && e.nodeId === RESOLVE_NODE && typeof e.sequence === 'number');
200
+ if (started === undefined) return softSkip('blocked', `the byok run's log has no node.started for ${RESOLVE_NODE} — no fork point before the resolve`);
201
+ const fork = await http(() => driver.post(`/runs/${enc(run.runId)}:fork`, { mode, fromSeq: started.sequence }));
202
+ if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
203
+ const forkId = (fork.json as { runId?: unknown } | undefined)?.runId;
204
+ if (fork.status !== 201 || typeof forkId !== 'string') return softSkip('blocked', `POST /runs/{runId}:fork {mode: ${mode}, fromSeq: ${String(started.sequence)}} answered ${fork.status} ${readErrorCode(fork.json) ?? ''} — v2-run-fork-refusals / v2-run-fork-prefix own the fork contract`.trim());
205
+ const forked = await readRun(forkId, fork, `fork (${mode})`); if ('reason' in forked) return softSkip('blocked', forked.reason);
206
+ const detectors = detectorsFor([...forked.docs, ...run.docs]);
207
+ if (detectors.length === 0) return softSkip('inapplicable', NO_DETECTOR);
208
+ const found = leaks(forked.surfaces, detectors);
209
+ expect(found, req(ID_FORK, DOC, `the plaintext MUST stay out of replay state — a ${mode} fork that re-resolved the canary carried it on: ${found.join(' | ')}`)).toEqual([]);
210
+ }, 90_000);
211
+ });