@openwop/openwop-conformance 2.45.2 → 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 (49) hide show
  1. package/CHANGELOG.md +52 -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 +1011 -43
  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-eval-mode-unadvertised-refused.test.ts +55 -0
  41. package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
  42. package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
  43. package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
  44. package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
  45. package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
  46. package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
  47. package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
  48. package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
  49. package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * RFC 0229 — the production-safe secret witness, shared by `secrets-run-witness`
3
+ * (major 1, `/v1/...`) and `v2-secrets-run-witness` (major 2, unversioned
4
+ * paths under `OpenWOP-Version: 2.0`, which the driver adds).
5
+ *
6
+ * The suite draws a fresh value `C` per run (48 CSPRNG bytes, base64url, 64
7
+ * characters, no fixed prefix) and supplies it as the top-level
8
+ * `runSecrets: [{ ref, value: C }]` of `createRun`. The fixture
9
+ * `openwop-secrets-run-witness` runs one `core.secret.witness` node that
10
+ * compares `sha256(C)` with an `expectedSha256` input and outputs only
11
+ * `{ matched }`. The scans read RAW response text, never parsed JSON, so an
12
+ * escaping difference cannot hide a hit (RFC 0229 Implementation notes).
13
+ *
14
+ * @see spec/v2/core/host-services.md §Run-supplied secrets
15
+ * @see spec/v1/capabilities.md §"Run-supplied secrets"
16
+ * @see RFCS/0229-production-safe-secrets-witness.md
17
+ */
18
+
19
+ import { createHash, randomBytes } from 'node:crypto';
20
+ import { driver, type OpenWOPResponse } from './driver.js';
21
+ import { loadEnv } from './env.js';
22
+ import { discoveryFamilies } from './discovery-capabilities.js';
23
+ import { isFixtureAdvertised } from './fixtures.js';
24
+
25
+ export const WITNESS_FIXTURE = 'openwop-secrets-run-witness';
26
+ export const WITNESS_NODE = 'witness';
27
+ export const WITNESS_REF = 'run:openwop-witness';
28
+ export const CANARY_NAME = 'openwop-conformance-canary-secret';
29
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
30
+
31
+ export type Major = 1 | 2;
32
+ export type Gate =
33
+ | { ok: true; maxEntries: number; branchFork: boolean }
34
+ | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
35
+
36
+ export const sha256Hex = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
37
+
38
+ /** A fresh witness value: 48 CSPRNG bytes, base64url, 64 characters, no fixed prefix. */
39
+ export function freshValue(): string {
40
+ return randomBytes(48).toString('base64url');
41
+ }
42
+
43
+ /** A fresh non-`run:` secret name (the scope leg's "random name"). */
44
+ export function freshName(): string {
45
+ return `openwop-witness-${randomBytes(9).toString('hex')}`;
46
+ }
47
+
48
+ /**
49
+ * Every form in which `C` "appears" (RFC 0229 §F row 2): raw; standard and
50
+ * URL-safe base64 of its UTF-8 bytes, padded and not; lower- and upper-case
51
+ * hex of those bytes; percent-encoded; JSON-string-escaped; and `sha256(C)`
52
+ * (lower and upper hex, and base64) — the digest is an output §B.3 forbids.
53
+ */
54
+ export function forms(c: string): ReadonlyArray<{ name: string; text: string }> {
55
+ const bytes = Buffer.from(c, 'utf8');
56
+ const b64 = bytes.toString('base64');
57
+ const b64url = bytes.toString('base64url');
58
+ const digest = createHash('sha256').update(bytes).digest();
59
+ const out = [
60
+ { name: 'raw', text: c },
61
+ { name: 'base64', text: b64 },
62
+ { name: 'base64 (unpadded)', text: b64.replace(/=+$/, '') },
63
+ { name: 'base64url', text: b64url },
64
+ { name: 'base64url (padded)', text: b64url + '='.repeat((4 - (b64url.length % 4)) % 4) },
65
+ { name: 'hex', text: bytes.toString('hex') },
66
+ { name: 'HEX', text: bytes.toString('hex').toUpperCase() },
67
+ { name: 'percent-encoded', text: encodeURIComponent(c) },
68
+ { name: 'JSON-escaped', text: JSON.stringify(c).slice(1, -1) },
69
+ { name: 'sha256 hex', text: digest.toString('hex') },
70
+ { name: 'SHA256 HEX', text: digest.toString('hex').toUpperCase() },
71
+ { name: 'sha256 base64', text: digest.toString('base64') },
72
+ ];
73
+ return out.filter((f, i) => out.findIndex((g) => g.text === f.text) === i);
74
+ }
75
+
76
+ /**
77
+ * The forms of `c` that occur in `text`. `digestSupplied`: the suite itself
78
+ * passed `sha256(C)` as the run's `expectedSha256` input, which a host may
79
+ * legitimately echo as an input, so the digest forms are not a leak for that
80
+ * run and are not scanned. They are scanned for every value whose digest the
81
+ * suite never sent (the mismatch run, and the retried value `C′`).
82
+ */
83
+ export function hits(text: string, c: string, digestSupplied = false): string[] {
84
+ return forms(c).filter((f) => !(digestSupplied && f.name.toLowerCase().startsWith('sha256'))).filter((f) => text.includes(f.text)).map((f) => f.name);
85
+ }
86
+
87
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
88
+ try { return await fn(); } catch { return null; }
89
+ }
90
+
91
+ export class WitnessHost {
92
+ constructor(readonly major: Major) {}
93
+
94
+ private p(path: string): string {
95
+ return this.major === 1 ? `/v1${path}` : path;
96
+ }
97
+
98
+ private enc(id: string): string {
99
+ return encodeURIComponent(id);
100
+ }
101
+
102
+ async discovery(): Promise<Record<string, unknown> | null> {
103
+ const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false }));
104
+ return res?.status === 200 && res.json && typeof res.json === 'object' ? discoveryFamilies(res.json) : null;
105
+ }
106
+
107
+ /**
108
+ * RFC 0229 §F dispositions: facet absent ⇒ `inapplicable` (the host offers
109
+ * no run-supplied secrets); facet advertised without the fixture ⇒ `blocked`
110
+ * (§C requires it). Discovery unreadable ⇒ `blocked`.
111
+ */
112
+ async gate(): Promise<Gate> {
113
+ const doc = await this.discovery();
114
+ if (doc === null) return { ok: false, kind: 'blocked', reason: '/.well-known/openwop did not answer 200 with a JSON body' };
115
+ const secrets = doc['secrets'];
116
+ const rs = secrets && typeof secrets === 'object' ? (secrets as Record<string, unknown>)['runSecrets'] : undefined;
117
+ if (rs === undefined || rs === null || typeof rs !== 'object') {
118
+ return { ok: false, kind: 'inapplicable', reason: 'secrets.runSecrets is not advertised — the host offers no run-supplied secrets (RFC 0229 §D)' };
119
+ }
120
+ if (!isFixtureAdvertised(WITNESS_FIXTURE)) {
121
+ return { ok: false, kind: 'blocked', reason: `secrets.runSecrets is advertised but the fixture ${WITNESS_FIXTURE} is not — RFC 0229 §C requires it, so the witness cannot run` };
122
+ }
123
+ const maxEntries = Number((rs as Record<string, unknown>)['maxEntries']);
124
+ const replay = doc['replay'];
125
+ const modes = replay && typeof replay === 'object' ? (replay as Record<string, unknown>)['modes'] : undefined;
126
+ const supported = this.major === 1 ? (replay as Record<string, unknown> | undefined)?.['supported'] === true : replay !== undefined;
127
+ return { ok: true, maxEntries: Number.isFinite(maxEntries) ? maxEntries : 1, branchFork: supported && Array.isArray(modes) && modes.includes('branch') };
128
+ }
129
+
130
+ /** `createRun` of the witness fixture. `runSecrets` is omitted when `undefined`. */
131
+ create(inputs: { ref: string; expectedSha256: string }, runSecrets?: ReadonlyArray<{ ref: string; value: string }>, idempotencyKey?: string): Promise<OpenWOPResponse | null> {
132
+ const body: Record<string, unknown> = { workflowId: WITNESS_FIXTURE, inputs };
133
+ if (runSecrets !== undefined) body['runSecrets'] = runSecrets;
134
+ return http(() => driver.post(this.p('/runs'), body, idempotencyKey === undefined ? {} : { headers: { 'Idempotency-Key': idempotencyKey } }));
135
+ }
136
+
137
+ async snapshot(runId: string): Promise<OpenWOPResponse | null> {
138
+ return http(() => driver.get(this.p(`/runs/${this.enc(runId)}`)));
139
+ }
140
+
141
+ async waitTerminal(runId: string, timeoutMs = 20_000): Promise<Record<string, unknown> | null> {
142
+ const deadline = Date.now() + timeoutMs;
143
+ for (;;) {
144
+ const res = await this.snapshot(runId);
145
+ const snap = res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
146
+ if (snap !== null && TERMINAL.has(String(snap['status']))) return snap;
147
+ if (Date.now() > deadline) return snap;
148
+ await new Promise((r) => setTimeout(r, 250));
149
+ }
150
+ }
151
+
152
+ async events(runId: string): Promise<{ text: string; events: Array<Record<string, unknown>> }> {
153
+ const res = await http(() => driver.get(this.p(`/runs/${this.enc(runId)}/events/poll?timeout=1`)));
154
+ let events: Array<Record<string, unknown>> = [];
155
+ const body = res?.json as { events?: unknown } | undefined;
156
+ if (Array.isArray(body?.events)) events = body.events as Array<Record<string, unknown>>;
157
+ if (events.length === 0 && this.major === 1) {
158
+ const alt = await http(() => driver.get(this.p(`/runs/${this.enc(runId)}/events`)));
159
+ const b = alt?.json as { events?: unknown } | undefined;
160
+ if (Array.isArray(b?.events)) return { text: `${res?.text ?? ''}\n${alt?.text ?? ''}`, events: b.events as Array<Record<string, unknown>> };
161
+ }
162
+ return { text: res?.text ?? '', events };
163
+ }
164
+
165
+ /** The raw SSE text under `streamMode=debug` — the most verbose mode (events.md §Stream modes). */
166
+ async debugStream(runId: string, timeoutMs = 6_000): Promise<string> {
167
+ const env = loadEnv();
168
+ const ctl = new AbortController();
169
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
170
+ try {
171
+ const res = await fetch(`${env.baseUrl}${this.p(`/runs/${this.enc(runId)}/events`)}?streamMode=debug`, {
172
+ headers: { Accept: 'text/event-stream', Authorization: `Bearer ${env.apiKey}`, ...(this.major === 2 ? { 'OpenWOP-Version': '2.0' } : {}) },
173
+ signal: ctl.signal,
174
+ });
175
+ if (res.body === null) return '';
176
+ try { return await res.text(); } catch { return ''; }
177
+ } catch {
178
+ return '';
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Every readable surface of a terminal run, as raw text: the create answer,
186
+ * the snapshot, the polled events, the `debug` stream, the run list (where
187
+ * served), and the debug bundle (where the seam serves one).
188
+ */
189
+ async surfaces(runId: string, extra: readonly string[] = []): Promise<{ text: string; read: string[] }> {
190
+ const read: string[] = [];
191
+ const parts: string[] = [...extra];
192
+ if (extra.length > 0) read.push('createRun response');
193
+ const snap = await this.snapshot(runId);
194
+ if (snap !== null) { parts.push(snap.text); read.push('snapshot'); }
195
+ const ev = await this.events(runId);
196
+ parts.push(ev.text); read.push('events (poll)');
197
+ const dbg = await this.debugStream(runId);
198
+ if (dbg.length > 0) { parts.push(dbg); read.push('events (streamMode=debug)'); }
199
+ const list = await http(() => driver.get(this.p('/runs?limit=50')));
200
+ if (list?.status === 200) { parts.push(list.text); read.push('listRuns'); }
201
+ const bundle = await http(() => driver.post('/v1/host/sample/test/debug-bundle/export', { runId }));
202
+ if (bundle?.status === 200) { parts.push(bundle.text); read.push('debug bundle'); }
203
+ return { text: parts.join('\n'), read };
204
+ }
205
+
206
+ /** `witness`'s `matched`, from `node.completed` outputs or the snapshot; `undefined` when not observable. */
207
+ async matched(runId: string, snap: Record<string, unknown> | null): Promise<boolean | undefined> {
208
+ const { events } = await this.events(runId);
209
+ for (const e of events) {
210
+ if (e['type'] !== 'node.completed') continue;
211
+ const payload = (e['payload'] ?? e['data'] ?? {}) as Record<string, unknown>;
212
+ if ((payload['nodeId'] ?? e['nodeId']) !== WITNESS_NODE) continue;
213
+ const outputs = (payload['outputs'] ?? e['outputs']) as Record<string, unknown> | undefined;
214
+ if (typeof outputs?.['matched'] === 'boolean') return outputs['matched'] as boolean;
215
+ }
216
+ for (const key of ['outputs', 'variables', 'nodeOutputs']) {
217
+ const bag = snap?.[key] as Record<string, unknown> | undefined;
218
+ const node = bag?.[WITNESS_NODE] as Record<string, unknown> | undefined;
219
+ if (typeof node?.['matched'] === 'boolean') return node['matched'] as boolean;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ /** `witness`'s `node.failed` error code; `null` when no `node.failed` names it. */
225
+ async nodeFailedCode(runId: string): Promise<string | null> {
226
+ const { events } = await this.events(runId);
227
+ for (const e of events) {
228
+ if (e['type'] !== 'node.failed') continue;
229
+ const payload = (e['payload'] ?? e['data'] ?? {}) as Record<string, unknown>;
230
+ if ((payload['nodeId'] ?? e['nodeId']) !== WITNESS_NODE) continue;
231
+ const err = (payload['error'] ?? e['error']) as Record<string, unknown> | undefined;
232
+ return typeof err?.['code'] === 'string' ? (err['code'] as string) : '';
233
+ }
234
+ return null;
235
+ }
236
+
237
+ fork(runId: string): Promise<OpenWOPResponse | null> {
238
+ return http(() => driver.post(this.p(`/runs/${this.enc(runId)}:fork`), { mode: 'branch', fromSeq: 0 }));
239
+ }
240
+ }
@@ -27,11 +27,11 @@
27
27
  */
28
28
 
29
29
  import { scenarioFileOfItId } from './requirement-ids.js';
30
- import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
30
+ import { PROFILE_FLOOR_SCENARIOS, floorMemberFiles } from './profiles.js';
31
31
  import { targetMajor } from './seams.js';
32
32
  import { PKG_ROOT_PATH } from './paths.js';
33
33
  import { v2ProfileFloorFiles } from './requirement-registry.js';
34
- import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
34
+ import { requirementIdForScenario, requirementIdForPrefix, requirementIdForAnyOf, requirementsFor, v2FloorsActive } from './requirement-registry.js';
35
35
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
36
36
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
37
37
  import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
@@ -39,10 +39,7 @@ import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-l
39
39
  /** All scenario basenames that appear in some profile's runtime floor. */
40
40
  export function floorScenarioFiles(): ReadonlySet<string> {
41
41
  const out = new Set<string>();
42
- for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) {
43
- for (const f of floor.required) out.add(f);
44
- for (const c of floor.conditional ?? []) for (const f of c.required) out.add(f);
45
- }
42
+ for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const f of floorMemberFiles(floor)) out.add(f);
46
43
  // At target major 2 the floors come from the declaration, not the v1 hand
47
44
  // table. The ledger and --certify MUST agree on this set, or a floor file is
48
45
  // minted `openwop.scenario.*` and looked up as `openwop.floor.*` — which is
@@ -77,12 +74,16 @@ export function requirementIdForFile(basename: string): string {
77
74
  */
78
75
  export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
79
76
 
77
+ /** `driver.ts`'s `TRANSPORT_LOSS_PREFIX`, restated so this module stays free of the driver (a test pins the two equal). */
78
+ export const TRANSPORT_LOSS_MARK = 'transport-loss: ';
79
+
80
80
  export type FileTestState = 'pass' | 'fail' | 'skip';
81
81
 
82
82
  /**
83
83
  * The per-`it` record (RFC 0148 §A at test granularity), as `setup.ts`
84
84
  * computes it in `afterEach`. Pure so a lib test can pin it:
85
85
  * - fail ⇒ executed-fail (detail = the first error message)
86
+ * - fail on a driver transport loss, 0 assertions ⇒ blocked (#1829: nothing was observed)
86
87
  * - pass with ≥ 1 assertion ⇒ executed-pass
87
88
  * - a behaviorGate entry journaled during the test ⇒ that gate's disposition
88
89
  * - pass with 0 assertions ⇒ the softSkip note written DURING THIS TEST
@@ -122,6 +123,15 @@ export function resolveItRecord(
122
123
  testName?: string,
123
124
  ): { disposition: Disposition; detail?: string } {
124
125
  if (state === 'fail') {
126
+ // #1829: the driver got no response (`TransportError`) before the test
127
+ // asserted anything. Nothing about the host was observed, so this is not a
128
+ // verdict on it. `blocked` still denies certification, so a flaky runner
129
+ // cannot certify; it just cannot convict the host either. A loss AFTER an
130
+ // assertion stays `executed-fail`: the test was mid-measurement, and a host
131
+ // that stops answering part-way is a finding.
132
+ if (assertionCalls === 0 && firstError !== undefined && firstError.includes(TRANSPORT_LOSS_MARK)) {
133
+ return { disposition: 'blocked', detail: firstError.slice(firstError.indexOf(TRANSPORT_LOSS_MARK), firstError.indexOf(TRANSPORT_LOSS_MARK) + 300) };
134
+ }
125
135
  const where = testName === undefined || testName.trim() === '' ? '' : ` in "${testName.slice(0, 120)}"`;
126
136
  return { disposition: 'executed-fail', detail: `the test executed and failed${where}: ${(firstError ?? 'no message').slice(0, 300)}` };
127
137
  }
@@ -225,6 +235,11 @@ export function fileDisposition(
225
235
  failures: readonly TestFailure[] = [],
226
236
  ): { disposition: Disposition; detail?: string } {
227
237
  const failed = states.filter((s) => s === 'fail').length;
238
+ // #1829, the file-level half of `resolveItRecord`'s rule: every failure was a
239
+ // driver transport loss and the file asserted nothing, so nothing was observed.
240
+ if (failed > 0 && assertionCount === 0 && failures.length === failed && failures.every((f) => f.message?.includes(TRANSPORT_LOSS_MARK) === true)) {
241
+ return { disposition: 'blocked', detail: `no response reached the suite in ${failed} test(s); first: ${failures[0]!.message!.slice(failures[0]!.message!.indexOf(TRANSPORT_LOSS_MARK)).slice(0, 300)}` };
242
+ }
228
243
  if (failed > 0) return { disposition: 'executed-fail', detail: failureDetail(failures, failed) };
229
244
  if (states.some((s) => s === 'pass')) {
230
245
  // A test that early-returned through `behaviorGate` is reported by vitest
@@ -458,6 +473,29 @@ export function deriveRequirementDispositions(
458
473
  rows.push(row);
459
474
  }
460
475
 
476
+ // Any-of requirements (RFC 0229 §E): one summary row per group, derived from
477
+ // its member files. Satisfied only by a WITNESSED pass (assertions > 0) with
478
+ // no member failing; `inapplicable`/`skipped` members never satisfy it. v1
479
+ // hand table only, like the prefix groups (the major-2 floors have none).
480
+ const groups = new Map<string, readonly string[]>();
481
+ if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const g of floor.requiredAnyOf ?? []) groups.set(requirementIdForAnyOf(g), g);
482
+ for (const [id, members] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
483
+ const scenarioId = `anyof:${members.join('|')}`;
484
+ const matching = members.map((f) => perFile.get(f)).filter((r): r is DerivedRequirement => r !== undefined);
485
+ const witnessed = matching.filter((r) => r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0);
486
+ let row: DerivedRequirement;
487
+ if (matching.some((r) => r.disposition === 'executed-fail')) {
488
+ row = { requirementId: id, scenarioId, disposition: 'executed-fail', detail: `an alternative failed: ${matching.filter((r) => r.disposition === 'executed-fail').map((r) => r.scenarioId).join(', ')}` };
489
+ } else if (witnessed.length > 0) {
490
+ row = { requirementId: id, scenarioId, disposition: 'executed-pass', assertionCount: witnessed.reduce((n, r) => n + (r.assertionCount ?? 0), 0) };
491
+ } else if (matching.length === 0) {
492
+ row = { requirementId: id, scenarioId, disposition: 'blocked', detail: `none of ${members.join(', ')} ran — unclassified return` };
493
+ } else {
494
+ row = { requirementId: id, scenarioId, disposition: 'blocked', detail: `no alternative recorded a witnessed pass (${matching.map((r) => `${r.scenarioId}: ${r.disposition}`).join(', ')})` };
495
+ }
496
+ rows.push(row);
497
+ }
498
+
461
499
  // Per-`it` rows (suite 1.153.0): every ledger entry keyed `openwop.it.<file>.<slug>`
462
500
  // becomes its own bundle row, attributed to its scenario file. Additive — the
463
501
  // file-level and prefix rows above are unchanged, and the floors still key on
@@ -513,7 +551,7 @@ export function deriveRequirementDispositions(
513
551
  const atMajor2 = v2FloorsActive();
514
552
  for (const id of ids) {
515
553
  const r = rowById.get(id);
516
- const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
554
+ const fromLedger = byId.has(id) || (r !== undefined && (r.scenarioId.endsWith('*') || r.scenarioId.startsWith('anyof:')));
517
555
  if (r !== undefined && r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0) witnessedPasses += 1;
518
556
  // Unclassified: no row, or a report-derived blocked (nothing recorded), or a
519
557
  // VACUOUS pass — executed-pass with assertionCount 0 is a witness of nothing
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Find a resolved secret on a readable surface — `host-services.md` §`secrets`:
3
+ * "Raw key material MUST NOT appear in any event, log, trace, prompt, error,
4
+ * export or screenshot".
5
+ *
6
+ * Two detectors, because the suite does not always know the secret:
7
+ *
8
+ * - **value** — the operator told the runner the canary's plaintext
9
+ * (`OPENWOP_CANARY_SECRET_VALUE`, the same agreement
10
+ * `secret-leakage-otel-attribute` uses). The text is searched for the value
11
+ * in every common encoding: raw, JSON-string-escaped, percent-encoded,
12
+ * standard and URL-safe base64 with and without padding, lower- and
13
+ * upper-case hex.
14
+ * - **digest** — the runner does not know the value, but the
15
+ * `openwop-smoke-byok-roundtrip` node surfaced `{secretSha256,
16
+ * secretLength}` (fixtures.md). Every window of `secretLength` bytes in
17
+ * every string of the surface — and in every base64 / hex / percent-decoded
18
+ * token inside it — is hashed and compared with the digest. A match is a
19
+ * preimage of the secret's digest on the surface: the secret itself.
20
+ *
21
+ * The digest itself is NOT a finding here: the byok fixture's contract emits it
22
+ * (fixtures.md §`openwop-smoke-byok-roundtrip`). RFC 0229's run witness, which
23
+ * forbids a digest, owns that stricter rule for the values it supplies.
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+
28
+ export interface SecretDetector {
29
+ /** What the detector knows, for the failure message — never the secret. */
30
+ readonly label: string;
31
+ /** The encoding in which the secret was found in `text`, or null. */
32
+ find(text: string): string | null;
33
+ }
34
+
35
+ /** Upper bound on hashed window positions per surface, so a huge body cannot stall a leg. */
36
+ const MAX_WINDOWS = 4_000_000;
37
+
38
+ function b64(buf: Buffer): string { return buf.toString('base64'); }
39
+ function b64url(buf: Buffer): string { return b64(buf).replace(/\+/g, '-').replace(/\//g, '_'); }
40
+
41
+ /** The known-value detector. `value` MUST be non-empty. */
42
+ export function valueDetector(value: string): SecretDetector {
43
+ const bytes = Buffer.from(value, 'utf8');
44
+ const forms: Array<[string, string]> = [
45
+ ['raw', value],
46
+ ['JSON-string-escaped', JSON.stringify(value).slice(1, -1)],
47
+ ['percent-encoded', encodeURIComponent(value)],
48
+ ['base64', b64(bytes)],
49
+ ['base64 (unpadded)', b64(bytes).replace(/=+$/, '')],
50
+ ['base64url', b64url(bytes)],
51
+ ['base64url (unpadded)', b64url(bytes).replace(/=+$/, '')],
52
+ ['hex', bytes.toString('hex')],
53
+ ['HEX', bytes.toString('hex').toUpperCase()],
54
+ ];
55
+ return {
56
+ label: `the operator-supplied canary value (OPENWOP_CANARY_SECRET_VALUE, ${bytes.length} bytes)`,
57
+ find(text) {
58
+ for (const [name, form] of forms) if (form.length > 0 && text.includes(form)) return name;
59
+ return null;
60
+ },
61
+ };
62
+ }
63
+
64
+ /** Every string (keys and values) of a parsed JSON document; the raw text when it is not JSON. */
65
+ function stringsOf(text: string): string[] {
66
+ let doc: unknown;
67
+ try { doc = JSON.parse(text); } catch { return [text]; }
68
+ const out: string[] = [];
69
+ const walk = (v: unknown): void => {
70
+ if (typeof v === 'string') out.push(v);
71
+ else if (Array.isArray(v)) v.forEach(walk);
72
+ else if (v !== null && typeof v === 'object') for (const [k, x] of Object.entries(v as Record<string, unknown>)) { out.push(k); walk(x); }
73
+ };
74
+ walk(doc);
75
+ return out;
76
+ }
77
+
78
+ /** The byte sequences a string can carry the secret in: itself, and its decodable tokens. */
79
+ function candidatesOf(s: string, minBytes: number): Array<[string, Buffer]> {
80
+ const out: Array<[string, Buffer]> = [['raw', Buffer.from(s, 'utf8')]];
81
+ if (s.includes('%')) { try { out.push(['percent-decoded', Buffer.from(decodeURIComponent(s), 'utf8')]); } catch { /* not percent-encoded */ } }
82
+ const minB64 = Math.max(4, Math.floor((minBytes * 4) / 3));
83
+ for (const tok of s.match(new RegExp(`[A-Za-z0-9+/_-]{${minB64},}={0,2}`, 'g')) ?? []) {
84
+ const buf = Buffer.from(tok.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
85
+ if (buf.length >= minBytes) out.push(['base64-decoded', buf]);
86
+ }
87
+ for (const tok of s.match(new RegExp(`(?:[0-9a-fA-F]{2}){${minBytes},}`, 'g')) ?? []) out.push(['hex-decoded', Buffer.from(tok, 'hex')]);
88
+ return out;
89
+ }
90
+
91
+ /** The digest detector: a preimage of `sha256Hex` of exactly `length` bytes anywhere on the surface. */
92
+ export function digestDetector(sha256Hex: string, length: number): SecretDetector {
93
+ const want = sha256Hex.toLowerCase();
94
+ return {
95
+ label: `a preimage of the secretSha256 the byok fixture surfaced (${length}-byte windows)`,
96
+ find(text) {
97
+ let budget = MAX_WINDOWS;
98
+ for (const s of stringsOf(text)) {
99
+ if (Buffer.byteLength(s, 'utf8') < length) continue; // no encoding makes a string longer than its decoding
100
+ for (const [enc, buf] of candidatesOf(s, length)) {
101
+ for (let i = 0; i + length <= buf.length; i += 1) {
102
+ if (budget-- <= 0) return null;
103
+ if (createHash('sha256').update(buf.subarray(i, i + length)).digest('hex') === want) return enc;
104
+ }
105
+ }
106
+ }
107
+ return null;
108
+ },
109
+ };
110
+ }
111
+
112
+ /** A named readable surface of a run, as the host answered it. */
113
+ export interface Surface { readonly name: string; readonly text: string }
114
+
115
+ /** The surfaces (by name) on which any detector finds the secret, with the encoding. */
116
+ export function leaks(surfaces: readonly Surface[], detectors: readonly SecretDetector[]): string[] {
117
+ const out: string[] = [];
118
+ for (const s of surfaces) {
119
+ for (const d of detectors) {
120
+ const enc = d.find(s.text);
121
+ if (enc !== null) { out.push(`${s.name} (${enc}, found by ${d.label})`); break; }
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** `{secretSha256, secretLength}` wherever a surface carries it (the byok node's outputs), or null. */
128
+ export function findSecretDigest(docs: readonly unknown[]): { sha256: string; length: number } | null {
129
+ let hit: { sha256: string; length: number } | null = null;
130
+ const walk = (v: unknown): void => {
131
+ if (hit !== null || v === null || typeof v !== 'object') return;
132
+ if (Array.isArray(v)) { v.forEach(walk); return; }
133
+ const o = v as Record<string, unknown>;
134
+ const sha = o['secretSha256'];
135
+ const len = o['secretLength'];
136
+ if (typeof sha === 'string' && /^[0-9a-f]{64}$/i.test(sha) && typeof len === 'number' && Number.isInteger(len) && len > 0) { hit = { sha256: sha, length: len }; return; }
137
+ Object.values(o).forEach(walk);
138
+ };
139
+ docs.forEach(walk);
140
+ return hit;
141
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The `OPENWOP_POLL_TIMEOUT_SCALE` knob, in its own module so `driver.ts` can
3
+ * scale its request bound without importing `polling.ts` (which imports the
4
+ * driver). `polling.ts` re-exports `scaledTimeoutMs`; its docstring explains
5
+ * the knob.
6
+ */
7
+
8
+ /**
9
+ * Multiplier applied to every poll bound (see the module docstring). Invalid,
10
+ * non-positive, or non-finite values fall back to `1` rather than silently
11
+ * producing a zero or negative deadline — a mis-set knob must not turn every
12
+ * poll into an instant failure that looks like a host defect.
13
+ */
14
+ function pollTimeoutScale(): number {
15
+ const raw = process.env.OPENWOP_POLL_TIMEOUT_SCALE;
16
+ if (raw === undefined || raw === '') return 1;
17
+ const n = Number(raw);
18
+ return Number.isFinite(n) && n > 0 ? n : 1;
19
+ }
20
+
21
+ /** Apply the scale to a bound, rounding up so a scale of 1 is exactly a no-op. */
22
+ export function scaledTimeoutMs(timeoutMs: number): number {
23
+ const scale = pollTimeoutScale();
24
+ return scale === 1 ? timeoutMs : Math.ceil(timeoutMs * scale);
25
+ }
@@ -20,7 +20,7 @@
20
20
  * @see spec/v1/trigger-bridge.md
21
21
  * @see spec/v1/profiles.md (§openwop-trigger-bridge)
22
22
  */
23
- import { randomBytes, randomInt } from 'node:crypto';
23
+ import { createHmac, randomBytes, randomInt } from 'node:crypto';
24
24
  import { driver } from './driver.js';
25
25
  import { deriveProfiles, type DiscoveryPayload } from './profiles.js';
26
26
 
@@ -121,3 +121,71 @@ export function freshStreamDedupKey(topic = 'events', partition = 3): string {
121
121
 
122
122
  export const SUBSCRIPTION_STATES = ['active', 'paused', 'failed', 'dead-lettered'];
123
123
  export const DELIVERY_OUTCOMES = ['delivered', 'retrying', 'dead-lettered'];
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // RFC 0230 — the normative-surface (seam-free) ingest path.
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** Does discovery advertise `triggerBridge.ingestion.inboundSigning` ∋ `standard-webhooks-1`? */
130
+ export async function inboundSigningAdvertised(): Promise<boolean> {
131
+ const disco = await driver.get('/.well-known/openwop');
132
+ if (disco.status !== 200 || !disco.json) return false;
133
+ const d = disco.json as Record<string, any>;
134
+ const tb = d.triggerBridge ?? d.capabilities?.triggerBridge;
135
+ const s = tb?.ingestion?.inboundSigning;
136
+ return Array.isArray(s) && s.includes('standard-webhooks-1');
137
+ }
138
+
139
+ export interface SignedWebhookSubscription {
140
+ subscriptionId: string;
141
+ ingestUrl: string;
142
+ signingSecret: string;
143
+ }
144
+
145
+ /**
146
+ * Register a `webhook` subscription bound to the `conformance-noop` fixture
147
+ * (RFC 0230 §B). Returns null (with a reason) when the registration does not
148
+ * yield the §B binding — the caller records that as a host defect, not a skip.
149
+ */
150
+ export async function registerSignedWebhook(mode: 'none' | 'required'): Promise<SignedWebhookSubscription | { reason: string }> {
151
+ const res = await driver.post('/v1/trigger-subscriptions', { source: 'webhook', workflowId: 'conformance-noop', verification: { mode } });
152
+ const body = res.json as { subscription?: { subscriptionId?: string }; binding?: { ingestUrl?: string; signingSecret?: string } } | undefined;
153
+ const subscriptionId = body?.subscription?.subscriptionId;
154
+ const ingestUrl = body?.binding?.ingestUrl;
155
+ const signingSecret = body?.binding?.signingSecret;
156
+ if (res.status !== 201 || !subscriptionId || !ingestUrl || !signingSecret) {
157
+ return { reason: `POST /v1/trigger-subscriptions answered ${res.status} without the RFC 0230 §B binding (subscriptionId, ingestUrl, signingSecret)` };
158
+ }
159
+ return { subscriptionId, ingestUrl, signingSecret };
160
+ }
161
+
162
+ /** A Standard Webhooks `webhook-id` (RFC 0201 grammar). */
163
+ export function freshWebhookId(): string {
164
+ return `msg_${randomBytes(12).toString('hex')}`;
165
+ }
166
+
167
+ /** `v1,<base64 HMAC-SHA256(key, "{id}.{ts}.{body}")>`, key = base64-decode after `whsec_`. */
168
+ export function standardWebhooksSignature(secret: string, id: string, ts: string, body: string): string {
169
+ const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
170
+ return `v1,${createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64')}`;
171
+ }
172
+
173
+ /**
174
+ * POST a raw body to `ingestUrl` with NO OpenWOP credential and the three
175
+ * Standard Webhooks headers (RFC 0230 §C). `signature` overrides the computed one
176
+ * (a bad-signature probe); `timestamp` overrides the current second (a skew probe).
177
+ */
178
+ export async function signedIngest(
179
+ sub: SignedWebhookSubscription,
180
+ body: string,
181
+ opts: { webhookId?: string; timestamp?: string; signature?: string } = {},
182
+ ): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
183
+ const id = opts.webhookId ?? freshWebhookId();
184
+ const ts = opts.timestamp ?? String(Math.floor(Date.now() / 1000));
185
+ const sig = opts.signature ?? standardWebhooksSignature(sub.signingSecret, id, ts, body);
186
+ const res = await driver.post(sub.ingestUrl, body, {
187
+ authenticated: false,
188
+ headers: { 'Content-Type': 'application/json', 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sig },
189
+ });
190
+ return { status: res.status, json: res.json as Record<string, unknown> | undefined };
191
+ }
@@ -9,7 +9,8 @@
9
9
  * This is a FLOOR scenario (`openwop.floor.byok-roundtrip`), so a host that
10
10
  * cannot run it has NOT witnessed the floor. When the canary fixture is not
11
11
  * advertised, or the start-run call answers 404/422, every test records
12
- * `blocked` with the reason — never a trivial pass. A production posture that
12
+ * `blocked` with the reason — never a trivial pass (unless the host takes the
13
+ * RFC 0229 run-witness floor path instead: then `inapplicable`). A production posture that
13
14
  * keeps the `conformance.secret.echo` seam off therefore leaves its claimed
14
15
  * `openwop-secrets` profile uncertified, and only that profile: the cut's
15
16
  * other claims still certify. Hosts that opt into `OPENWOP_CONFORMANCE_FIXTURES=1`
@@ -39,6 +40,7 @@ import { pollUntilTerminal } from '../lib/polling.js';
39
40
  import { isFixtureAdvertised } from '../lib/fixtures.js';
40
41
  import { req } from '../lib/requirement-ids.js';
41
42
  import { softSkip } from '../lib/soft-skip.js';
43
+ import { WITNESS_FIXTURE, WitnessHost } from '../lib/run-secrets-witness.js';
42
44
 
43
45
  /** SHA-256 hex regex — 64 lowercase hex chars exactly. */
44
46
  const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
@@ -47,9 +49,23 @@ const BYOK_WORKFLOW_ID = 'openwop-smoke-byok-roundtrip';
47
49
  const SKIP_NO_FIXTURE = !isFixtureAdvertised(BYOK_WORKFLOW_ID);
48
50
  const NO_FIXTURE_REASON = `precondition not met — the \`${BYOK_WORKFLOW_ID}\` fixture is not advertised, so this host cannot witness the BYOK roundtrip floor (the \`conformance.secret.echo\` seam and the \`openwop-conformance-canary-secret\` canary are off)`;
49
51
 
52
+ /**
53
+ * RFC 0229 §E: `openwop-secrets` has a second floor path, `secrets-run-witness`.
54
+ * A host that withholds the canary but advertises `secrets.runSecrets` and the
55
+ * `openwop-secrets-run-witness` fixture takes that path, so this file is
56
+ * `inapplicable` on it, not `blocked` — a `blocked` row would deny the whole
57
+ * bundle (RFC 0168 §E.1) for a floor the host satisfies the other way.
58
+ */
59
+ async function noFixture(): Promise<undefined> {
60
+ if (isFixtureAdvertised(WITNESS_FIXTURE) && (await new WitnessHost(1).gate()).ok) {
61
+ return softSkip('inapplicable', `the \`${BYOK_WORKFLOW_ID}\` fixture is withheld and the host takes the run-witness floor path instead (secrets.runSecrets and \`${WITNESS_FIXTURE}\` are advertised; RFC 0229 §E, profiles.md §openwop-secrets)`);
62
+ }
63
+ return softSkip('blocked', NO_FIXTURE_REASON);
64
+ }
65
+
50
66
  describe('byok: end-to-end credentialRef resolution roundtrip (openwop-byok profile)', () => {
51
67
  it('the canary fixture run MUST resolve a host-provisioned secret and emit SHA-256 hex', async () => {
52
- if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
68
+ if (SKIP_NO_FIXTURE) return noFixture();
53
69
  const create = await driver.post('/v1/runs', {
54
70
  workflowId: 'openwop-smoke-byok-roundtrip',
55
71
  });
@@ -114,7 +130,7 @@ describe('byok: end-to-end credentialRef resolution roundtrip (openwop-byok prof
114
130
  });
115
131
 
116
132
  it('BYOK fixture run MUST emit a node.completed event for the resolve step', async () => {
117
- if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
133
+ if (SKIP_NO_FIXTURE) return noFixture();
118
134
  const create = await driver.post('/v1/runs', {
119
135
  workflowId: 'openwop-smoke-byok-roundtrip',
120
136
  });
@@ -141,7 +157,7 @@ describe('byok: end-to-end credentialRef resolution roundtrip (openwop-byok prof
141
157
  });
142
158
 
143
159
  it('BYOK fixture run event log MUST NOT echo the resolved secret value (redaction)', async () => {
144
- if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
160
+ if (SKIP_NO_FIXTURE) return noFixture();
145
161
  const create = await driver.post('/v1/runs', {
146
162
  workflowId: 'openwop-smoke-byok-roundtrip',
147
163
  });