@openwop/openwop-conformance 2.45.1 → 2.45.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +2 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/lib/certification-bundle-verify.js +22 -1
  5. package/dist/lib/profiles.js +17 -2
  6. package/dist/lib/requirement-registry.js +15 -0
  7. package/dist/lib/scenario-disposition.js +50 -9
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/fixtures/conformance-fs-probe.json +25 -0
  10. package/fixtures/conformance-queue-consume.json +27 -0
  11. package/fixtures/conformance-queue-publish.json +29 -0
  12. package/fixtures/conformance-safefetch-probe.json +23 -0
  13. package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
  14. package/fixtures/conformance-storage-probe.json +27 -0
  15. package/fixtures/conformance-tool-scope-probe.json +27 -0
  16. package/fixtures/openwop-secrets-run-witness.json +29 -0
  17. package/fixtures.md +102 -1
  18. package/package.json +2 -2
  19. package/requirement-aliases.json +72 -71
  20. package/requirements.json +1017 -49
  21. package/scenario-majors.json +39 -3
  22. package/schemas/CORPUS-STAMP.json +22 -22
  23. package/src/cli.ts +1 -1
  24. package/src/lib/certification-bundle-verify.ts +23 -2
  25. package/src/lib/driver.ts +61 -2
  26. package/src/lib/memoryAttribution.ts +41 -6
  27. package/src/lib/polling.ts +2 -18
  28. package/src/lib/profiles.ts +26 -2
  29. package/src/lib/requirement-registry.ts +12 -0
  30. package/src/lib/run-secrets-witness.ts +240 -0
  31. package/src/lib/scenario-disposition.ts +45 -7
  32. package/src/lib/secret-scan.ts +141 -0
  33. package/src/lib/timeout-scale.ts +25 -0
  34. package/src/lib/triggerBridge.ts +69 -1
  35. package/src/scenarios/byok-roundtrip.test.ts +20 -4
  36. package/src/scenarios/runner-ledger.test.ts +6 -1
  37. package/src/scenarios/secrets-run-witness.test.ts +154 -0
  38. package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
  39. package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
  40. package/src/scenarios/v2-error-registry.test.ts +7 -10
  41. package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
  42. package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
  43. package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
  44. package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
  45. package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
  46. package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
  47. package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
  48. package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
  49. package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
  50. package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
@@ -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
+ });
@@ -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
+ });