@openwop/openwop-conformance 2.45.2 → 2.45.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +2 -2
- package/dist/cli.js +1 -1
- package/dist/lib/certification-bundle-verify.js +22 -1
- package/dist/lib/profiles.js +17 -2
- package/dist/lib/requirement-registry.js +15 -0
- package/dist/lib/scenario-disposition.js +60 -9
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-budget-tool-calls.json +81 -0
- package/fixtures/conformance-fs-probe.json +25 -0
- package/fixtures/conformance-queue-consume.json +27 -0
- package/fixtures/conformance-queue-publish.json +29 -0
- package/fixtures/conformance-safefetch-probe.json +23 -0
- package/fixtures/conformance-secret-resolve-then-fail.json +33 -0
- package/fixtures/conformance-storage-probe.json +27 -0
- package/fixtures/conformance-tool-scope-probe.json +27 -0
- package/fixtures/openwop-secrets-run-witness.json +29 -0
- package/fixtures.md +113 -1
- package/package.json +2 -2
- package/requirement-aliases.json +72 -71
- package/requirements.json +1081 -43
- package/scenario-majors.json +45 -3
- package/schemas/CORPUS-STAMP.json +22 -22
- package/src/cli.ts +1 -1
- package/src/lib/backpressure-witness.ts +163 -0
- package/src/lib/budget-witness.ts +165 -0
- package/src/lib/certification-bundle-verify.ts +23 -2
- package/src/lib/driver.ts +61 -2
- package/src/lib/major-profile.ts +93 -0
- package/src/lib/memoryAttribution.ts +41 -6
- package/src/lib/polling.ts +2 -18
- package/src/lib/profiles.ts +26 -2
- package/src/lib/requirement-registry.ts +12 -0
- package/src/lib/run-secrets-witness.ts +240 -0
- package/src/lib/scenario-disposition.ts +54 -7
- package/src/lib/scratch-host.ts +130 -0
- package/src/lib/secret-scan.ts +141 -0
- package/src/lib/timeout-scale.ts +25 -0
- package/src/lib/triggerBridge.ts +69 -1
- package/src/scenarios/byok-roundtrip.test.ts +20 -4
- package/src/scenarios/runner-ledger.test.ts +2 -0
- package/src/scenarios/secrets-run-witness.test.ts +154 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +183 -126
- package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +141 -0
- package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
- package/src/scenarios/v2-eval-mode-unadvertised-refused.test.ts +55 -0
- package/src/scenarios/v2-fs-sandbox-escape-refused.test.ts +161 -0
- package/src/scenarios/v2-memory-cross-tenant-isolation.test.ts +113 -0
- package/src/scenarios/v2-production-backpressure.test.ts +74 -0
- package/src/scenarios/v2-queue-cross-tenant-isolation.test.ts +118 -0
- package/src/scenarios/v2-safefetch-ssrf-refused.test.ts +198 -0
- package/src/scenarios/v2-secret-canary-absent.test.ts +211 -0
- package/src/scenarios/v2-secrets-run-witness.test.ts +167 -0
- package/src/scenarios/v2-storage-cross-tenant-isolation.test.ts +213 -0
- package/src/scenarios/v2-tool-authorization-fail-closed.test.ts +197 -0
- package/src/scenarios/v2-workspace-scope-from-identity.test.ts +146 -0
|
@@ -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 { DEPRECATED_PROFILE_ALIASES, 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,38 @@ 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
|
+
//
|
|
482
|
+
// Only for a CLAIMED profile's floor (2.45.4). The row is `blocked` when no
|
|
483
|
+
// member was witnessed, and a v1 floor reads `inapplicable` as certifiable, so
|
|
484
|
+
// the row cannot simply say `inapplicable`. But 2.45.3 wrote it for every
|
|
485
|
+
// floor in the table: a host that does not advertise secrets records both
|
|
486
|
+
// members `inapplicable`, got one `blocked` row for a profile it never
|
|
487
|
+
// claimed, and a bundle with any `blocked` row certifies nothing (RFC 0168
|
|
488
|
+
// §E.1). A group nobody claims is not a requirement on this host.
|
|
489
|
+
const claimed = new Set(claimedProfiles.map((p) => DEPRECATED_PROFILE_ALIASES[p] ?? p));
|
|
490
|
+
if (!v2FloorsActive()) for (const [profile, floor] of Object.entries(PROFILE_FLOOR_SCENARIOS)) if (claimed.has(profile)) for (const g of floor.requiredAnyOf ?? []) groups.set(requirementIdForAnyOf(g), g);
|
|
491
|
+
for (const [id, members] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
492
|
+
const scenarioId = `anyof:${members.join('|')}`;
|
|
493
|
+
const matching = members.map((f) => perFile.get(f)).filter((r): r is DerivedRequirement => r !== undefined);
|
|
494
|
+
const witnessed = matching.filter((r) => r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0);
|
|
495
|
+
let row: DerivedRequirement;
|
|
496
|
+
if (matching.some((r) => r.disposition === 'executed-fail')) {
|
|
497
|
+
row = { requirementId: id, scenarioId, disposition: 'executed-fail', detail: `an alternative failed: ${matching.filter((r) => r.disposition === 'executed-fail').map((r) => r.scenarioId).join(', ')}` };
|
|
498
|
+
} else if (witnessed.length > 0) {
|
|
499
|
+
row = { requirementId: id, scenarioId, disposition: 'executed-pass', assertionCount: witnessed.reduce((n, r) => n + (r.assertionCount ?? 0), 0) };
|
|
500
|
+
} else if (matching.length === 0) {
|
|
501
|
+
row = { requirementId: id, scenarioId, disposition: 'blocked', detail: `none of ${members.join(', ')} ran — unclassified return` };
|
|
502
|
+
} else {
|
|
503
|
+
row = { requirementId: id, scenarioId, disposition: 'blocked', detail: `no alternative recorded a witnessed pass (${matching.map((r) => `${r.scenarioId}: ${r.disposition}`).join(', ')})` };
|
|
504
|
+
}
|
|
505
|
+
rows.push(row);
|
|
506
|
+
}
|
|
507
|
+
|
|
461
508
|
// Per-`it` rows (suite 1.153.0): every ledger entry keyed `openwop.it.<file>.<slug>`
|
|
462
509
|
// becomes its own bundle row, attributed to its scenario file. Additive — the
|
|
463
510
|
// file-level and prefix rows above are unchanged, and the floors still key on
|
|
@@ -513,7 +560,7 @@ export function deriveRequirementDispositions(
|
|
|
513
560
|
const atMajor2 = v2FloorsActive();
|
|
514
561
|
for (const id of ids) {
|
|
515
562
|
const r = rowById.get(id);
|
|
516
|
-
const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
|
|
563
|
+
const fromLedger = byId.has(id) || (r !== undefined && (r.scenarioId.endsWith('*') || r.scenarioId.startsWith('anyof:')));
|
|
517
564
|
if (r !== undefined && r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0) witnessedPasses += 1;
|
|
518
565
|
// Unclassified: no row, or a report-derived blocked (nothing recorded), or a
|
|
519
566
|
// VACUOUS pass — executed-pass with assertionCount 0 is a witness of nothing
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scratch host: the smallest HTTP server a shared witness can be proven
|
|
3
|
+
* against, in both directions, inside the suite's own self-tests.
|
|
4
|
+
*
|
|
5
|
+
* Some families are advertised by no reference host, so a new witness for them
|
|
6
|
+
* cannot be sabotage-proved against a real one. A witness that was never seen
|
|
7
|
+
* to fail is not a witness. The scratch host serves a discovery document and a
|
|
8
|
+
* scripted run surface, and each self-test turns on ONE defect to show the
|
|
9
|
+
* witness convicts it.
|
|
10
|
+
*
|
|
11
|
+
* It is a test double, not a host: it executes nothing, and nothing it does is
|
|
12
|
+
* evidence about any implementation. It takes a {@link MajorProfile}, so the
|
|
13
|
+
* same double proves the next major's port.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
17
|
+
import type { AddressInfo } from 'node:net';
|
|
18
|
+
import type { MajorProfile } from './major-profile.js';
|
|
19
|
+
|
|
20
|
+
export interface ScriptedEvent { readonly type: string; readonly payload?: Record<string, unknown> | undefined }
|
|
21
|
+
export interface ScriptedRun {
|
|
22
|
+
readonly status: 'completed' | 'failed' | 'running';
|
|
23
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
24
|
+
readonly events: readonly ScriptedEvent[];
|
|
25
|
+
}
|
|
26
|
+
export interface ScratchRefusal {
|
|
27
|
+
readonly status: number;
|
|
28
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
29
|
+
readonly body: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface ScratchOptions {
|
|
32
|
+
readonly profile: MajorProfile;
|
|
33
|
+
/** The discovery document, served as given. */
|
|
34
|
+
readonly discovery: Readonly<Record<string, unknown>>;
|
|
35
|
+
/** What a created run looks like, by workflowId and request body. Default: an empty running run. */
|
|
36
|
+
readonly script?: ((workflowId: string, body: Readonly<Record<string, unknown>>) => ScriptedRun) | undefined;
|
|
37
|
+
/** Concurrent in-flight requests admitted before {@link refusal} answers. Unset: no cap. */
|
|
38
|
+
readonly inflightCap?: number | undefined;
|
|
39
|
+
readonly refusal?: ScratchRefusal | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StoredRun { readonly id: string; run: ScriptedRun }
|
|
43
|
+
|
|
44
|
+
export class ScratchHost {
|
|
45
|
+
private server: Server | undefined;
|
|
46
|
+
private readonly runs = new Map<string, StoredRun>();
|
|
47
|
+
private inflight = 0;
|
|
48
|
+
private seq = 0;
|
|
49
|
+
url = '';
|
|
50
|
+
|
|
51
|
+
constructor(private opts: ScratchOptions) {}
|
|
52
|
+
|
|
53
|
+
/** Swap behaviour between cases. The address stays the same, since the driver caches its base URL per file. */
|
|
54
|
+
reconfigure(next: Partial<ScratchOptions>): void {
|
|
55
|
+
this.opts = { ...this.opts, ...next };
|
|
56
|
+
this.runs.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async start(): Promise<string> {
|
|
60
|
+
this.server = createServer((req, res) => { void this.handle(req, res); });
|
|
61
|
+
await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
|
|
62
|
+
this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
|
63
|
+
return this.url;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stop(): Promise<void> {
|
|
67
|
+
const s = this.server;
|
|
68
|
+
if (s === undefined) return;
|
|
69
|
+
s.closeAllConnections();
|
|
70
|
+
await new Promise<void>((r) => s.close(() => r()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private send(res: ServerResponse, status: number, body: unknown, headers: Readonly<Record<string, string>> = {}): void {
|
|
74
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
75
|
+
res.end(JSON.stringify(body));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async body(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
79
|
+
const chunks: Buffer[] = [];
|
|
80
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
81
|
+
try {
|
|
82
|
+
const v: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
83
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
84
|
+
} catch { return {}; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
88
|
+
const path = (req.url ?? '/').split('?')[0] ?? '/';
|
|
89
|
+
const runs = this.opts.profile.runsPath;
|
|
90
|
+
// Discovery is never counted against the cap.
|
|
91
|
+
if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.opts.discovery);
|
|
92
|
+
|
|
93
|
+
const cap = this.opts.inflightCap;
|
|
94
|
+
if (cap !== undefined && this.inflight >= cap) {
|
|
95
|
+
const r = this.opts.refusal ?? { status: 503, headers: { 'retry-after': '1' }, body: { error: 'service_unavailable', message: 'at capacity' } };
|
|
96
|
+
return this.send(res, r.status, r.body, r.headers);
|
|
97
|
+
}
|
|
98
|
+
this.inflight++;
|
|
99
|
+
res.on('close', () => { this.inflight--; });
|
|
100
|
+
|
|
101
|
+
if (req.method === 'POST' && path === runs) {
|
|
102
|
+
const body = await this.body(req);
|
|
103
|
+
const workflowId = String(body['workflowId'] ?? '');
|
|
104
|
+
const id = `scratch/run-${++this.seq}`;
|
|
105
|
+
this.runs.set(id, { id, run: this.opts.script?.(workflowId, body) ?? { status: 'running', events: [] } });
|
|
106
|
+
return this.send(res, 201, { runId: id, status: 'running' });
|
|
107
|
+
}
|
|
108
|
+
if (req.method === 'POST' && path === `${runs}:bulk-cancel`) return this.send(res, 200, { results: [] });
|
|
109
|
+
|
|
110
|
+
const m = new RegExp(`^${runs}/([^/]+)(/events/poll|/events|/cancel)?$`).exec(path);
|
|
111
|
+
const stored = m ? this.runs.get(decodeURIComponent(m[1] as string)) : undefined;
|
|
112
|
+
if (!m || stored === undefined) return this.send(res, 404, { error: 'not_found', message: 'no such route or run' });
|
|
113
|
+
const tail = m[2];
|
|
114
|
+
if (tail === '/cancel') return this.send(res, 200, { status: 'cancelled' });
|
|
115
|
+
if (tail === '/events') {
|
|
116
|
+
// Held open until the client aborts: this is what occupies a slot.
|
|
117
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
118
|
+
res.write(': held\n\n');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (tail === '/events/poll') {
|
|
122
|
+
const after = Number(new URL(req.url ?? '/', 'http://x').searchParams.get('afterSequence') ?? '0');
|
|
123
|
+
const events = stored.run.events
|
|
124
|
+
.map((e, i) => ({ eventId: `e${i + 1}`, runId: stored.id, type: e.type, payload: e.payload ?? {}, sequence: i + 1, schemaVersion: 2, timestamp: new Date(0).toISOString() }))
|
|
125
|
+
.filter((e) => e.sequence > after);
|
|
126
|
+
return this.send(res, 200, { events });
|
|
127
|
+
}
|
|
128
|
+
return this.send(res, 200, { runId: stored.id, workflowId: 'scratch', status: stored.run.status, ...(stored.run.error === undefined ? {} : { error: stored.run.error }) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/triggerBridge.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
|
@@ -135,6 +135,8 @@ describe('RFC 0148 §A (S6) — the runner derivation', () => {
|
|
|
135
135
|
expect(p.disposition).toBe('executed-pass');
|
|
136
136
|
expect(p.scenarioId).toBe(`${prefix}*`);
|
|
137
137
|
}
|
|
138
|
+
// An any-of group's summary row is written only for a claimed profile's floor
|
|
139
|
+
// (2.45.4), so this floor's rows are all there is and every one passed.
|
|
138
140
|
expect(d.totals.executedPass).toBe(d.requirements.length);
|
|
139
141
|
});
|
|
140
142
|
|