@dogfood-lab/ingest 1.7.0 → 1.9.0
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/lib/unsafe-segment.js +15 -7
- package/load-context.js +267 -36
- package/package.json +1 -1
- package/persist.js +9 -1
- package/run.js +300 -20
package/lib/unsafe-segment.js
CHANGED
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
* - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
|
|
7
7
|
* - packages/findings/derive/load-records.js (the missing third callsite)
|
|
8
8
|
*
|
|
9
|
-
* The check rejects path-traversal substrings (`..`)
|
|
10
|
-
* (`/`, `\`)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The check rejects path-traversal substrings (`..`), any path separator
|
|
10
|
+
* (`/`, `\`), and a segment that is EXACTLY `.` (F-3d2e6edf: path.join
|
|
11
|
+
* normalizes a lone-dot segment away, so a schema-valid repo like `./x`
|
|
12
|
+
* filed records one directory level UP — records/_rejected/x/... at the org
|
|
13
|
+
* level — and resolved policies/repos/./x.yaml to policies/repos/x.yaml;
|
|
14
|
+
* the verify CLI's safe() helper already blocked `.`, and the two guards
|
|
15
|
+
* must agree). EMBEDDED dots remain legal because GitHub permits dotted
|
|
16
|
+
* org/repo names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The
|
|
17
|
+
* submission schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees
|
|
18
|
+
* on characters (though it still admits a lone-dot segment — this guard is
|
|
19
|
+
* the backstop).
|
|
13
20
|
*
|
|
14
21
|
* F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
|
|
15
|
-
* legitimate submissions inside writeRecord. The narrower
|
|
16
|
-
* stood since wave 9; this helper is the
|
|
22
|
+
* legitimate submissions inside writeRecord. The narrower form (now
|
|
23
|
+
* `/^\.$|\.\.|[/\\]/`) has stood since wave 9; this helper is the
|
|
24
|
+
* productized form.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
/**
|
|
@@ -22,7 +30,7 @@
|
|
|
22
30
|
*
|
|
23
31
|
* @type {RegExp}
|
|
24
32
|
*/
|
|
25
|
-
export const UNSAFE_SEGMENT =
|
|
33
|
+
export const UNSAFE_SEGMENT = /^\.$|\.\.|[/\\]/;
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* Predicate form: returns true when the given segment contains a path-traversal
|
package/load-context.js
CHANGED
|
@@ -129,7 +129,14 @@ export function loadGlobalPolicy(repoRoot) {
|
|
|
129
129
|
* @returns {object|null|{ __torn: true, reason: string, path: string }}
|
|
130
130
|
*/
|
|
131
131
|
export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
132
|
-
|
|
132
|
+
// F-54e5fde7: the submission contract is strictly two-segment (the schema's
|
|
133
|
+
// `repo` pattern forbids a second slash; nested GitLab subgroups are
|
|
134
|
+
// UNSUPPORTED). A 3+-segment slug must fail closed here — the old 2-way
|
|
135
|
+
// destructure silently dropped the third segment and would have resolved a
|
|
136
|
+
// DIFFERENT repo's policy file (group/subgroup/project → repos/group/subgroup.yaml).
|
|
137
|
+
const segments = repoSlug.split('/');
|
|
138
|
+
if (segments.length !== 2) return null;
|
|
139
|
+
const [org, repo] = segments;
|
|
133
140
|
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
|
|
134
141
|
const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
|
|
135
142
|
|
|
@@ -215,6 +222,50 @@ export const GITHUB_SCENARIO_FETCH_ATTEMPTS = 3;
|
|
|
215
222
|
const RETRY_BASE_MS = 250;
|
|
216
223
|
const RETRY_MAX_MS = 2000;
|
|
217
224
|
|
|
225
|
+
/**
|
|
226
|
+
* V2-CROSS-BO-002: byte cap on a fetched scenario body. A scenario definition
|
|
227
|
+
* crosses the consumer-repo → verifier trust boundary; without a cap a
|
|
228
|
+
* hostile/misconfigured source repo could stream an arbitrarily large body
|
|
229
|
+
* into memory and into yaml.load. Real scenario files are a few KB — 1 MiB is
|
|
230
|
+
* three orders of magnitude of headroom. Checked against Content-Length
|
|
231
|
+
* BEFORE the body is read (when the response exposes it), then enforced
|
|
232
|
+
* WHILE the body streams (F-35486d38: the reader aborts the moment the
|
|
233
|
+
* running byte count crosses the cap, so a header-suppressed oversized body
|
|
234
|
+
* never fully buffers). Responses without a streamable body (test stubs)
|
|
235
|
+
* fall back to text() + a post-read length check, which then bounds only
|
|
236
|
+
* yaml.load, not the buffering step.
|
|
237
|
+
*/
|
|
238
|
+
export const GITHUB_SCENARIO_MAX_BYTES = 1024 * 1024;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* F-2750c4e8: hard ceiling on DISTINCT scenario ids fetched per submission.
|
|
242
|
+
* Matches the submission schema's `scenario_results` maxItems (1000) — the
|
|
243
|
+
* published contract bound — so a schema-valid submission can never hit it,
|
|
244
|
+
* while a hostile pre-schema-gate payload (the fetch loop runs before
|
|
245
|
+
* verify()'s schema rejection lands) cannot drive unbounded sequential
|
|
246
|
+
* authenticated GitHub API calls. Combined with the attempted-id dedupe in
|
|
247
|
+
* loadScenarios, the worst-case network spend per submission is bounded by
|
|
248
|
+
* this constant regardless of payload shape.
|
|
249
|
+
*/
|
|
250
|
+
export const MAX_DISTINCT_SCENARIO_FETCHES = 1000;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* V2-CROSS-BO-001: classified operational fault for the scenario fetch —
|
|
254
|
+
* mirrors the F-dac7e08c adapter discipline in
|
|
255
|
+
* `packages/verify/validators/provenance.js` (transport rejects and non-404
|
|
256
|
+
* provider statuses THROW after the bounded retry, they never masquerade as
|
|
257
|
+
* "the file does not exist"). The `scenario-fetch-fault:` prefix is
|
|
258
|
+
* registered in `@dogfood-lab/verify`'s parseRejectionReason as
|
|
259
|
+
* 'operational', so any surface that stringifies this error routes the
|
|
260
|
+
* incident to ops instead of bouncing a good submission back to the
|
|
261
|
+
* submitter.
|
|
262
|
+
*/
|
|
263
|
+
function scenarioFetchFault(detail) {
|
|
264
|
+
const err = new Error(`scenario-fetch-fault: ${detail}`);
|
|
265
|
+
err.code = 'SCENARIO_FETCH_FAULT';
|
|
266
|
+
return err;
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
/** Default async backoff. Injectable (`opts.sleepImpl`) so tests run instantly. */
|
|
219
270
|
function defaultSleep(ms) {
|
|
220
271
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -231,9 +282,11 @@ function defaultSleep(ms) {
|
|
|
231
282
|
* - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
|
|
232
283
|
* returns `{ scenario, reason }` where `scenario` is the loaded
|
|
233
284
|
* object on success or `null` on failure, and `reason` is one of
|
|
234
|
-
* `'
|
|
235
|
-
* failure (absent on success). The reason gives
|
|
236
|
-
* pivot key when diagnosing a stale scenario-load chain.
|
|
285
|
+
* `'not_found' | 'parse_error' | 'invalid_id' | 'too_large' |
|
|
286
|
+
* 'schema_invalid'` on failure (absent on success). The reason gives
|
|
287
|
+
* operators a pivot key when diagnosing a stale scenario-load chain.
|
|
288
|
+
* A timeout that survives every retry no longer RETURNS a typed
|
|
289
|
+
* reason — it throws `scenario-fetch-fault:` (F-07ab7f86, see below).
|
|
237
290
|
*
|
|
238
291
|
* Both surfaces honour the per-request AbortController timeout
|
|
239
292
|
* (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
|
|
@@ -241,10 +294,23 @@ function defaultSleep(ms) {
|
|
|
241
294
|
* INGEST-PROACT-003: each call makes up to `opts.attempts`
|
|
242
295
|
* (`GITHUB_SCENARIO_FETCH_ATTEMPTS`) tries with exponential backoff, retrying
|
|
243
296
|
* ONLY the transient classes — request timeout, HTTP 5xx, HTTP 429, and network
|
|
244
|
-
* rejects. A 404 (`not_found`), an `invalid_id`,
|
|
245
|
-
*
|
|
246
|
-
* EPERM/EBUSY-only discipline in
|
|
247
|
-
*
|
|
297
|
+
* rejects. A 404 (`not_found`), an `invalid_id`, a `parse_error`, a
|
|
298
|
+
* `too_large`, and a `schema_invalid` are DEFINITIVE answers and are returned
|
|
299
|
+
* immediately without a retry (mirrors the EPERM/EBUSY-only discipline in
|
|
300
|
+
* `lib/rename-with-retry.js`). The backoff sleep is injectable
|
|
301
|
+
* (`opts.sleepImpl`) so tests do not actually wait.
|
|
302
|
+
*
|
|
303
|
+
* V2-CROSS-BO-001 + F-07ab7f86: exhausting the retry budget on a 5xx/429, a
|
|
304
|
+
* transport reject, OR a per-request timeout, or hitting any other non-404
|
|
305
|
+
* non-ok status (401/403 bad credential), THROWS a `scenario-fetch-fault:`
|
|
306
|
+
* classified error instead of returning a typed reason — an outage, a slow-API
|
|
307
|
+
* window, or a token fault is an OPERATIONAL incident, never evidence that
|
|
308
|
+
* the scenario file is absent. Callers (loadScenarios → run.js) propagate the
|
|
309
|
+
* throw so the CLI exits 2 without persisting a rejected record. The
|
|
310
|
+
* exhausted-timeout fault keeps the literal word 'timeout' in its detail so
|
|
311
|
+
* the D1B-004/L1-007 operator pivot key survives (contract updated wave 4;
|
|
312
|
+
* pre-fix the typed 'timeout' reason became a submission-rejecting
|
|
313
|
+
* `scenario-load:` reason that poisoned the run_id via the duplicate guard).
|
|
248
314
|
*
|
|
249
315
|
* @param {string} token - GitHub PAT
|
|
250
316
|
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
|
|
@@ -258,7 +324,13 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
258
324
|
const attempts = opts.attempts ?? GITHUB_SCENARIO_FETCH_ATTEMPTS;
|
|
259
325
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
260
326
|
|
|
261
|
-
|
|
327
|
+
// V2-CROSS-BO-003 (F-54e5fde7 family): the submission contract is strictly
|
|
328
|
+
// two-segment. The old 2-way destructure silently dropped a third segment —
|
|
329
|
+
// and worse, the URL below was built from the RAW slug, so `org/repo/extra`
|
|
330
|
+
// reached the authenticated GitHub API verbatim. Fail closed here and build
|
|
331
|
+
// the URL from the VALIDATED segments only.
|
|
332
|
+
const segments = typeof repoSlug === 'string' ? repoSlug.split('/') : [];
|
|
333
|
+
const [org, repo] = segments.length === 2 ? segments : [null, null];
|
|
262
334
|
// commitSha is interpolated into the authenticated (Bearer-token) GitHub API
|
|
263
335
|
// URL's `?ref=` — a shape guard (lowercase-hex, 7–40 chars) refuses anything
|
|
264
336
|
// that could re-target the ref or inject query params, matching how org/repo
|
|
@@ -274,11 +346,14 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
274
346
|
};
|
|
275
347
|
}
|
|
276
348
|
|
|
277
|
-
// One bounded attempt. Returns `{ scenario, reason, retryable }`;
|
|
278
|
-
// below
|
|
349
|
+
// One bounded attempt. Returns `{ scenario, reason, retryable, fault }`;
|
|
350
|
+
// the loop below retries on `retryable` and THROWS `fault` (when present)
|
|
351
|
+
// once the budget is exhausted — so a transient blip is ridden out, but a
|
|
352
|
+
// real outage surfaces as an operational fault, never as `not_found`.
|
|
279
353
|
async function attemptOnce(scenarioId) {
|
|
280
354
|
const path = `dogfood/scenarios/${scenarioId}.yaml`;
|
|
281
|
-
|
|
355
|
+
// V2-CROSS-BO-003: built from the validated org/repo, never the raw slug.
|
|
356
|
+
const url = `https://api.github.com/repos/${org}/${repo}/contents/${path}?ref=${commitSha}`;
|
|
282
357
|
|
|
283
358
|
// D1B-004: AbortController-bounded request. Copied from
|
|
284
359
|
// `packages/verify/validators/provenance.js:80-104`. The 30s default
|
|
@@ -298,33 +373,110 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
298
373
|
signal: controller.signal
|
|
299
374
|
});
|
|
300
375
|
if (!resp.ok) {
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
376
|
+
// V2-CROSS-BO-001: only a 404 means "the scenario file is absent" —
|
|
377
|
+
// that is the sole not_found. A 5xx / 429 is a transient provider
|
|
378
|
+
// fault: retry, then throw operational on exhaustion. Anything else
|
|
379
|
+
// (401/403 bad credential, unexpected 4xx) is a definitive
|
|
380
|
+
// OPERATIONAL fault: throw immediately, never retried, never
|
|
381
|
+
// misread as a missing file.
|
|
382
|
+
if (resp.status === 404) {
|
|
383
|
+
return { scenario: null, reason: 'not_found', retryable: false };
|
|
384
|
+
}
|
|
385
|
+
const fault = scenarioFetchFault(
|
|
386
|
+
`GitHub API HTTP ${resp.status} loading scenario "${scenarioId}" from ${org}/${repo}`
|
|
387
|
+
);
|
|
388
|
+
if (resp.status >= 500 || resp.status === 429) {
|
|
389
|
+
return { scenario: null, retryable: true, fault };
|
|
390
|
+
}
|
|
391
|
+
throw fault;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// V2-CROSS-BO-002: byte cap at the trust boundary. Check the declared
|
|
395
|
+
// Content-Length first (skips even starting an oversized read when the
|
|
396
|
+
// response exposes headers) — the header is attacker-suppliable, so the
|
|
397
|
+
// read below re-enforces the cap on real bytes.
|
|
398
|
+
const declaredLength = typeof resp.headers?.get === 'function'
|
|
399
|
+
? Number(resp.headers.get('content-length'))
|
|
400
|
+
: NaN;
|
|
401
|
+
if (declaredLength > GITHUB_SCENARIO_MAX_BYTES) {
|
|
402
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
403
|
+
}
|
|
404
|
+
// F-35486d38: stream the body with a running byte count and abort the
|
|
405
|
+
// moment the cap is crossed, so a header-suppressed oversized body is
|
|
406
|
+
// never fully buffered. Responses without a streamable body (test
|
|
407
|
+
// stubs, non-spec fetch impls) fall back to text() + post-read check,
|
|
408
|
+
// which bounds yaml.load but not the buffering step.
|
|
409
|
+
if (resp.body && typeof resp.body.getReader === 'function') {
|
|
410
|
+
const reader = resp.body.getReader();
|
|
411
|
+
const chunks = [];
|
|
412
|
+
let received = 0;
|
|
413
|
+
let overflow = false;
|
|
414
|
+
for (;;) {
|
|
415
|
+
const { done, value } = await reader.read();
|
|
416
|
+
if (done) break;
|
|
417
|
+
received += value.byteLength;
|
|
418
|
+
if (received > GITHUB_SCENARIO_MAX_BYTES) {
|
|
419
|
+
overflow = true;
|
|
420
|
+
// cancel() may reject if the connection is already torn down —
|
|
421
|
+
// the too_large verdict below stands either way.
|
|
422
|
+
await reader.cancel().catch(() => {});
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
chunks.push(value);
|
|
426
|
+
}
|
|
427
|
+
if (overflow) {
|
|
428
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
429
|
+
}
|
|
430
|
+
text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
431
|
+
} else {
|
|
432
|
+
text = await resp.text();
|
|
433
|
+
if (Buffer.byteLength(text, 'utf8') > GITHUB_SCENARIO_MAX_BYTES) {
|
|
434
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
435
|
+
}
|
|
305
436
|
}
|
|
306
|
-
text = await resp.text();
|
|
307
437
|
} catch (err) {
|
|
438
|
+
if (err && err.code === 'SCENARIO_FETCH_FAULT') {
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
308
441
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
309
442
|
// A timed-out request may succeed on a retry.
|
|
310
443
|
return { scenario: null, reason: 'timeout', retryable: true };
|
|
311
444
|
}
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
445
|
+
// V2-CROSS-BO-001: network reject, DNS failure, etc. — transient, so
|
|
446
|
+
// retry; but on exhaustion this is an outage (operational fault), NOT
|
|
447
|
+
// evidence the scenario file is absent.
|
|
448
|
+
return {
|
|
449
|
+
scenario: null,
|
|
450
|
+
retryable: true,
|
|
451
|
+
fault: scenarioFetchFault(
|
|
452
|
+
`network error loading scenario "${scenarioId}" from ${org}/${repo}: ${err && err.message ? err.message : String(err)}`
|
|
453
|
+
)
|
|
454
|
+
};
|
|
315
455
|
} finally {
|
|
316
456
|
clearTimeout(timer);
|
|
317
457
|
}
|
|
318
458
|
|
|
459
|
+
let scenario;
|
|
319
460
|
try {
|
|
320
|
-
|
|
321
|
-
if (!scenario || typeof scenario !== 'object') {
|
|
322
|
-
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
323
|
-
}
|
|
324
|
-
return { scenario };
|
|
461
|
+
scenario = yaml.load(text);
|
|
325
462
|
} catch {
|
|
326
463
|
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
327
464
|
}
|
|
465
|
+
if (!scenario || typeof scenario !== 'object') {
|
|
466
|
+
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// V2-CROSS-BO-002: schema-gate the loaded object before it crosses into
|
|
470
|
+
// verify()'s required-steps enforcement. A parses-but-nonconforming
|
|
471
|
+
// scenario is a SUBMISSION-SIDE fault (the source repo authored it) — it
|
|
472
|
+
// surfaces through loadScenarios as a typed `schema_invalid` reason and
|
|
473
|
+
// becomes a `scenario-load:` rejection at the ingest layer, never a
|
|
474
|
+
// validator fault.
|
|
475
|
+
const validation = validatePayload('scenario', scenario);
|
|
476
|
+
if (!validation.valid) {
|
|
477
|
+
return { scenario: null, reason: 'schema_invalid', retryable: false };
|
|
478
|
+
}
|
|
479
|
+
return { scenario };
|
|
328
480
|
}
|
|
329
481
|
|
|
330
482
|
async function fetchWithReason(scenarioId) {
|
|
@@ -338,6 +490,26 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
338
490
|
if (last.scenario || !last.retryable || i === attempts - 1) break;
|
|
339
491
|
await sleep(Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS));
|
|
340
492
|
}
|
|
493
|
+
// V2-CROSS-BO-001: a transient fault that survived every retry is an
|
|
494
|
+
// outage — throw the classified operational error instead of returning a
|
|
495
|
+
// reason the ingest layer would turn into a submission rejection.
|
|
496
|
+
if (last.fault) {
|
|
497
|
+
throw last.fault;
|
|
498
|
+
}
|
|
499
|
+
// F-07ab7f86: timeout EXHAUSTION is outage-shaped too — a sustained
|
|
500
|
+
// slow-API window (every attempt >timeoutMs) is the same operational
|
|
501
|
+
// class as exhausted ECONNREFUSED/5xx, not evidence the submission is
|
|
502
|
+
// bad. Throw the classified fault, keeping the literal word 'timeout'
|
|
503
|
+
// in the detail so the D1B-004/L1-007 operator pivot key survives in
|
|
504
|
+
// the error message. (This deliberately supersedes the earlier pinned
|
|
505
|
+
// contract where exhausted-timeout returned the typed reason and became
|
|
506
|
+
// a `scenario-load:` rejection — that permanently poisoned run_ids
|
|
507
|
+
// during slow-API windows via the _rejected duplicate guard.)
|
|
508
|
+
if (last.reason === 'timeout') {
|
|
509
|
+
throw scenarioFetchFault(
|
|
510
|
+
`timeout after ${attempts} attempt(s) loading scenario "${scenarioId}" from ${org}/${repo}`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
341
513
|
// Strip the internal `retryable` flag from the public contract.
|
|
342
514
|
const { retryable: _drop, ...result } = last;
|
|
343
515
|
return result;
|
|
@@ -357,32 +529,91 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
357
529
|
/**
|
|
358
530
|
* Load all scenario definitions referenced by a submission's scenario_results.
|
|
359
531
|
*
|
|
360
|
-
*
|
|
361
|
-
* the
|
|
362
|
-
* `
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
532
|
+
* F-efe4f893: this function now runs BEFORE the schema gate on untrusted
|
|
533
|
+
* consumer input (the F-3bfc2885 production wiring), so it must be defensive:
|
|
534
|
+
* - a non-array `scenario_results` returns empty-handed (the schema
|
|
535
|
+
* rejection downstream in verify() is the authoritative signal);
|
|
536
|
+
* - an entry that is not a non-null object carrying a string `scenario_id`
|
|
537
|
+
* is NEVER fetched — pre-fix, `/^[\w-]+$/.test(undefined)` coerced to the
|
|
538
|
+
* string 'undefined' and issued a real authenticated GitHub API request
|
|
539
|
+
* per malformed entry. It pushes a typed `malformed_entry` error instead.
|
|
540
|
+
*
|
|
541
|
+
* F-2750c4e8: every ATTEMPTED id is cached (successes AND typed failures), so
|
|
542
|
+
* a repeated id — loaded or failed — costs exactly one fetch, and the number
|
|
543
|
+
* of distinct fetches is capped at {@link MAX_DISTINCT_SCENARIO_FETCHES}
|
|
544
|
+
* (the schema's own maxItems bound). Entries beyond the cap get a typed
|
|
545
|
+
* `fetch_cap` error without touching the network. Thrown
|
|
546
|
+
* `scenario-fetch-fault:`s are deliberately NOT cached — they propagate
|
|
547
|
+
* immediately (see below), so outage semantics survive the dedupe.
|
|
548
|
+
*
|
|
549
|
+
* DESIGN RULING (wave 4) — three-way failure split:
|
|
550
|
+
* - true 404 (`not_found`, typed surface only) → NOT a rejection. The fleet
|
|
551
|
+
* is mixed (some consumers commit dogfood/scenarios/, some don't);
|
|
552
|
+
* nothing declared means nothing to enforce. Surfaces on `warnings` so
|
|
553
|
+
* run.js lands it on verification.warnings (the v1.6.0
|
|
554
|
+
* accepted-with-warning channel) and required-steps enforcement is
|
|
555
|
+
* simply skipped for that scenario.
|
|
556
|
+
* - malformed committed file (`parse_error` / `schema_invalid` /
|
|
557
|
+
* `too_large` / `invalid_id`) → `errors` → submission-bad
|
|
558
|
+
* `scenario-load:` rejection (unchanged).
|
|
559
|
+
* - outage (`scenario-fetch-fault:` throw, incl. exhausted timeout) →
|
|
560
|
+
* PROPAGATES deliberately; the CLI's outer catch emits a structured
|
|
561
|
+
* operational error and exits 2 without persisting (V2-CROSS-BO-001).
|
|
562
|
+
*
|
|
563
|
+
* The legacy `fetch(id)` truthiness surface cannot discriminate not_found
|
|
564
|
+
* from a malformed file, so its `null` keeps the historical
|
|
565
|
+
* rejection-on-error behavior.
|
|
367
566
|
*
|
|
368
567
|
* @param {object} submission
|
|
369
568
|
* @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
|
|
370
|
-
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
|
|
569
|
+
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[], warnings: string[] }>}
|
|
371
570
|
*/
|
|
372
571
|
export async function loadScenarios(submission, scenarioFetcher) {
|
|
373
572
|
const scenarios = new Map();
|
|
374
573
|
const errors = [];
|
|
574
|
+
const warnings = [];
|
|
575
|
+
|
|
576
|
+
const results = submission && typeof submission === 'object'
|
|
577
|
+
? submission.scenario_results
|
|
578
|
+
: undefined;
|
|
579
|
+
if (!Array.isArray(results)) {
|
|
580
|
+
// Non-array shapes (string, object, null) iterate wrong or crash —
|
|
581
|
+
// notably a STRING iterates characters. Skip entirely; verify()'s schema
|
|
582
|
+
// gate rejects the submission with the authoritative reason.
|
|
583
|
+
return { scenarios, errors, warnings };
|
|
584
|
+
}
|
|
375
585
|
|
|
376
586
|
const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
|
|
587
|
+
// F-2750c4e8: id → true once an id has been resolved (success OR typed
|
|
588
|
+
// failure), so repeats never re-fetch. Faults are uncached: they throw.
|
|
589
|
+
const attempted = new Set();
|
|
590
|
+
let distinctFetches = 0;
|
|
377
591
|
|
|
378
|
-
for (const sr of
|
|
592
|
+
for (const sr of results) {
|
|
593
|
+
if (!sr || typeof sr !== 'object' || Array.isArray(sr) || typeof sr.scenario_id !== 'string') {
|
|
594
|
+
errors.push(
|
|
595
|
+
'scenario_results entry is not an object with a string scenario_id (reason: malformed_entry)'
|
|
596
|
+
);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
379
599
|
const id = sr.scenario_id;
|
|
380
|
-
if (
|
|
600
|
+
if (attempted.has(id)) continue;
|
|
601
|
+
attempted.add(id);
|
|
602
|
+
|
|
603
|
+
if (distinctFetches >= MAX_DISTINCT_SCENARIO_FETCHES) {
|
|
604
|
+
errors.push(
|
|
605
|
+
`scenario "${id}" not fetched — distinct-scenario fetch cap (${MAX_DISTINCT_SCENARIO_FETCHES}) reached (reason: fetch_cap)`
|
|
606
|
+
);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
distinctFetches++;
|
|
381
610
|
|
|
382
611
|
if (supportsTypedReason) {
|
|
383
612
|
const result = await scenarioFetcher.fetchWithReason(id);
|
|
384
613
|
if (result && result.scenario) {
|
|
385
614
|
scenarios.set(id, result.scenario);
|
|
615
|
+
} else if (result && result.reason === 'not_found') {
|
|
616
|
+
warnings.push(`scenario definition not found for "${id}" — required_steps unenforced`);
|
|
386
617
|
} else {
|
|
387
618
|
const reason = result && result.reason ? result.reason : 'unknown';
|
|
388
619
|
errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
|
|
@@ -397,5 +628,5 @@ export async function loadScenarios(submission, scenarioFetcher) {
|
|
|
397
628
|
}
|
|
398
629
|
}
|
|
399
630
|
|
|
400
|
-
return { scenarios, errors };
|
|
631
|
+
return { scenarios, errors, warnings };
|
|
401
632
|
}
|
package/package.json
CHANGED
package/persist.js
CHANGED
|
@@ -43,7 +43,15 @@ export function computeRecordPath(record, repoRoot) {
|
|
|
43
43
|
const status = record.verification?.status;
|
|
44
44
|
const base = status === 'rejected' ? 'records/_rejected' : 'records';
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// V2-CROSS-BO-003 (F-54e5fde7 family): strictly two segments — the old
|
|
47
|
+
// destructure silently dropped a third segment (`group/subgroup/project`
|
|
48
|
+
// filed under `group/subgroup/`), sharding the record into the WRONG
|
|
49
|
+
// repo's path. Fail closed instead.
|
|
50
|
+
const segments = (record.repo || '').split('/');
|
|
51
|
+
if (segments.length !== 2) {
|
|
52
|
+
throw new Error(`invalid repo format: ${record.repo}`);
|
|
53
|
+
}
|
|
54
|
+
const [org, repo] = segments;
|
|
47
55
|
if (!org || !repo) {
|
|
48
56
|
throw new Error(`invalid repo format: ${record.repo}`);
|
|
49
57
|
}
|
package/run.js
CHANGED
|
@@ -24,7 +24,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
24
24
|
import { verify } from '@dogfood-lab/verify';
|
|
25
25
|
import { stubProvenance, provenanceForProvider } from '@dogfood-lab/verify/validators/provenance.js';
|
|
26
26
|
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
27
|
-
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios } from './load-context.js';
|
|
27
|
+
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios, githubScenarioFetcher } from './load-context.js';
|
|
28
28
|
import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
|
|
29
29
|
import { rebuildIndexes } from './rebuild-indexes.js';
|
|
30
30
|
import { verifyChain, formatChainResult } from './verify-chain.js';
|
|
@@ -44,6 +44,27 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
44
44
|
* @param {object} submission
|
|
45
45
|
* @returns {{ provenance: object } | { err: Error }}
|
|
46
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* F-a2e40a09: the ONE definition of "running in CI" for the provenance
|
|
49
|
+
* branches. Truthy check on either conventional variable — CI systems export
|
|
50
|
+
* CI=1 / CI=yes / CI=true interchangeably. Previously the stub-forbidden
|
|
51
|
+
* guard used this truthy check while the real-by-default branch demanded the
|
|
52
|
+
* exact string 'true', so a CI=1 environment was "CI" for one branch and
|
|
53
|
+
* "not CI" for the other (fail-closed, but with a misleading flag-required
|
|
54
|
+
* error instead of the token hint).
|
|
55
|
+
*
|
|
56
|
+
* F-5ca6c91a: the literal strings 'false' and '0' are explicit OPT-OUTS
|
|
57
|
+
* (a convention many tools respect — `CI=false npm test` is a real idiom),
|
|
58
|
+
* not truthy CI signals. Without this carve-out, a CI=false environment was
|
|
59
|
+
* classified as CI: stub provenance forbidden and the no-flag default
|
|
60
|
+
* demanding a real token — fail-closed, but against the operator's stated
|
|
61
|
+
* intent.
|
|
62
|
+
*/
|
|
63
|
+
function isCI() {
|
|
64
|
+
const truthy = (v) => Boolean(v && v !== 'false' && v !== '0');
|
|
65
|
+
return truthy(process.env.CI) || truthy(process.env.GITHUB_ACTIONS);
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
function resolveProviderProvenance(submission) {
|
|
48
69
|
const provider = (submission && submission.source && submission.source.provider) || 'github';
|
|
49
70
|
const factory = provenanceForProvider(provider);
|
|
@@ -60,6 +81,71 @@ function resolveProviderProvenance(submission) {
|
|
|
60
81
|
return { provenance: factory(token) };
|
|
61
82
|
}
|
|
62
83
|
|
|
84
|
+
/**
|
|
85
|
+
* V2-INVARIAN-004: the ONE definition of "which submissions get a scenario
|
|
86
|
+
* fetcher" (and with it required-steps enforcement). Previously this
|
|
87
|
+
* condition lived inline in the CLI entrypoint where it was untestable
|
|
88
|
+
* without spawning a process.
|
|
89
|
+
*
|
|
90
|
+
* Returns a `githubScenarioFetcher` when ALL hold:
|
|
91
|
+
* - provenance is REAL (not stubProvenance — preview/test runs never hit
|
|
92
|
+
* the GitHub API);
|
|
93
|
+
* - the submission is a plain object with provider `github` (default when
|
|
94
|
+
* `source.provider` is absent) — GitLab has no contents-API adapter yet,
|
|
95
|
+
* so its required_steps gate remains unenforced (documented gap);
|
|
96
|
+
* - `submission.repo` and `submission.ref.commit_sha` are strings (the
|
|
97
|
+
* fetcher re-validates their shapes fail-closed);
|
|
98
|
+
* - a GitHub token is present in `env` (GITHUB_TOKEN, then GH_TOKEN — the
|
|
99
|
+
* same token resolveProviderProvenance validated).
|
|
100
|
+
*
|
|
101
|
+
* Otherwise returns null: verification proceeds without scenario
|
|
102
|
+
* definitions, exactly as before F-3bfc2885 wired the production path.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} provenance - resolved provenance adapter
|
|
105
|
+
* @param {object} submission
|
|
106
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
107
|
+
* @returns {ReturnType<typeof githubScenarioFetcher> | null}
|
|
108
|
+
*/
|
|
109
|
+
export function resolveScenarioFetcher(provenance, submission, env = process.env) {
|
|
110
|
+
return resolveScenarioFetcherDecision(provenance, submission, env).fetcher;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* F-b04473d5: the reason-carrying form of {@link resolveScenarioFetcher}.
|
|
115
|
+
* Every ineligible branch previously returned a silent null — an operator
|
|
116
|
+
* auditing an accepted record could not tell "required_steps enforced and
|
|
117
|
+
* satisfied" from "enforcement skipped (GitLab gap / missing token)" in the
|
|
118
|
+
* run log. The CLI logs the decision as a `scenario_enforcement` NDJSON
|
|
119
|
+
* event; this function is the unit-testable seam behind it.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} provenance
|
|
122
|
+
* @param {object} submission
|
|
123
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
124
|
+
* @returns {{ fetcher: ReturnType<typeof githubScenarioFetcher> | null,
|
|
125
|
+
* active: boolean, reason: string | null }} `reason` names the skip class
|
|
126
|
+
* when `active` is false; null when a fetcher was constructed.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveScenarioFetcherDecision(provenance, submission, env = process.env) {
|
|
129
|
+
const skip = (reason) => ({ fetcher: null, active: false, reason });
|
|
130
|
+
if (provenance === stubProvenance) return skip('stub-provenance');
|
|
131
|
+
if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
|
|
132
|
+
return skip('submission-malformed');
|
|
133
|
+
}
|
|
134
|
+
if (((submission.source && submission.source.provider) || 'github') !== 'github') {
|
|
135
|
+
// GitLab has no contents-API adapter yet — the documented gap.
|
|
136
|
+
return skip('provider-unsupported');
|
|
137
|
+
}
|
|
138
|
+
if (typeof submission.repo !== 'string') return skip('repo-not-string');
|
|
139
|
+
if (typeof submission.ref?.commit_sha !== 'string') return skip('commit-sha-not-string');
|
|
140
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN;
|
|
141
|
+
if (!token) return skip('no-token');
|
|
142
|
+
return {
|
|
143
|
+
fetcher: githubScenarioFetcher(token, submission.repo, submission.ref.commit_sha),
|
|
144
|
+
active: true,
|
|
145
|
+
reason: null
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
63
149
|
/**
|
|
64
150
|
* SEED-1 (d3-ingest-003) — posixify a path-shaped value at the operator/log
|
|
65
151
|
* SERIALIZATION boundary. `computeRecordPath`/`writeRecord` return OS-native
|
|
@@ -81,6 +167,50 @@ function posixifyPath(p) {
|
|
|
81
167
|
return typeof p === 'string' ? p.split(sep).join('/') : p;
|
|
82
168
|
}
|
|
83
169
|
|
|
170
|
+
/**
|
|
171
|
+
* F-2750c4e8: ceiling on individually-persisted `scenario-load:` rejection
|
|
172
|
+
* reasons. A hostile pre-schema-gate payload can produce up to
|
|
173
|
+
* MAX_DISTINCT_SCENARIO_FETCHES scenario-load errors; persisting one reason
|
|
174
|
+
* line per error bloats the evidence record without adding operator signal
|
|
175
|
+
* (a 1000-error submission has one root cause). Everything past the ceiling
|
|
176
|
+
* collapses into a single count summary.
|
|
177
|
+
*/
|
|
178
|
+
const SCENARIO_LOAD_REASON_CEILING = 20;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Format loadScenarios errors as `scenario-load:` rejection reasons,
|
|
182
|
+
* collapsing past {@link SCENARIO_LOAD_REASON_CEILING}.
|
|
183
|
+
*
|
|
184
|
+
* @param {string[]} scenarioErrors
|
|
185
|
+
* @returns {string[]}
|
|
186
|
+
*/
|
|
187
|
+
function scenarioLoadReasons(scenarioErrors) {
|
|
188
|
+
const reasons = scenarioErrors.map(e => `scenario-load: ${e}`);
|
|
189
|
+
if (reasons.length <= SCENARIO_LOAD_REASON_CEILING) return reasons;
|
|
190
|
+
return [
|
|
191
|
+
...reasons.slice(0, SCENARIO_LOAD_REASON_CEILING),
|
|
192
|
+
`scenario-load: (+${reasons.length - SCENARIO_LOAD_REASON_CEILING} more scenario-load errors collapsed; ${reasons.length} total)`
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* DESIGN RULING (wave 4): loadScenarios' `warnings` (true-404 scenario
|
|
198
|
+
* definitions — nothing declared, nothing to enforce) land on the v1.6.0
|
|
199
|
+
* accepted-with-warning channel (`verification.warnings`), NEVER on
|
|
200
|
+
* rejection_reasons. verify() only sets the field when policy warnings
|
|
201
|
+
* exist, so create-or-append here.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} record - the record returned by verify()
|
|
204
|
+
* @param {string[]} scenarioWarnings
|
|
205
|
+
*/
|
|
206
|
+
function appendScenarioWarnings(record, scenarioWarnings) {
|
|
207
|
+
if (!scenarioWarnings || scenarioWarnings.length === 0) return;
|
|
208
|
+
record.verification.warnings = [
|
|
209
|
+
...(record.verification.warnings || []),
|
|
210
|
+
...scenarioWarnings
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
84
214
|
/**
|
|
85
215
|
* Emit a single structured stage-transition log line via the shared helper.
|
|
86
216
|
*
|
|
@@ -146,6 +276,27 @@ function resolveCorrelationId(submission) {
|
|
|
146
276
|
return synthCorrelationId();
|
|
147
277
|
}
|
|
148
278
|
|
|
279
|
+
/**
|
|
280
|
+
* F-41872706: count how many loaded scenario definitions actually carry an
|
|
281
|
+
* enforceable required_steps gate. `scenarios.size` alone answers "how many
|
|
282
|
+
* definitions were fetched"; this answers "how many of those could fire the
|
|
283
|
+
* step-results-present / step-verdict-consistent reject rules" — a definition
|
|
284
|
+
* with no (or empty) success_criteria.required_steps enforces nothing (see
|
|
285
|
+
* verify/index.js: requiredSteps defaults to [] and the loop no-ops).
|
|
286
|
+
*
|
|
287
|
+
* @param {Map|null} scenarios - the Map returned by loadScenarios, or null
|
|
288
|
+
* @returns {number}
|
|
289
|
+
*/
|
|
290
|
+
function countRequiredStepsGates(scenarios) {
|
|
291
|
+
if (!scenarios || typeof scenarios.values !== 'function') return 0;
|
|
292
|
+
let gates = 0;
|
|
293
|
+
for (const definition of scenarios.values()) {
|
|
294
|
+
const steps = definition?.success_criteria?.required_steps;
|
|
295
|
+
if (Array.isArray(steps) && steps.length > 0) gates++;
|
|
296
|
+
}
|
|
297
|
+
return gates;
|
|
298
|
+
}
|
|
299
|
+
|
|
149
300
|
/**
|
|
150
301
|
* Run the full ingestion pipeline.
|
|
151
302
|
*
|
|
@@ -226,11 +377,26 @@ export async function ingest(submission, options) {
|
|
|
226
377
|
repo_policy_present: !!repoPolicy
|
|
227
378
|
});
|
|
228
379
|
|
|
229
|
-
// 3. Load scenario definitions
|
|
380
|
+
// 3. Load scenario definitions.
|
|
381
|
+
// F-3bfc2885: keep the loaded `scenarios` Map (previously discarded) and hand
|
|
382
|
+
// it to verify() so success_criteria.required_steps is actually enforced.
|
|
383
|
+
// F-efe4f893: gate on Array.isArray — this runs BEFORE the schema gate on
|
|
384
|
+
// untrusted input, and a string/object scenario_results must never reach the
|
|
385
|
+
// fetch loop (a string iterates CHARACTERS). verify() lands the
|
|
386
|
+
// authoritative schema rejection downstream.
|
|
387
|
+
// DESIGN RULING (wave 4): a true-404 definition is a WARNING (nothing
|
|
388
|
+
// declared, nothing to enforce), a malformed committed file is a
|
|
389
|
+
// `scenario-load:` rejection, and V2-CROSS-BO-001 outages (incl. exhausted
|
|
390
|
+
// timeout, F-07ab7f86) throw `scenario-fetch-fault:` PAST this step — the
|
|
391
|
+
// CLI's outer catch emits a structured operational error and exits 2.
|
|
230
392
|
let scenarioErrors = [];
|
|
231
|
-
|
|
393
|
+
let scenarioWarnings = [];
|
|
394
|
+
let scenarios = null;
|
|
395
|
+
if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
|
|
232
396
|
const result = await loadScenarios(submission, scenarioFetcher);
|
|
233
397
|
scenarioErrors = result.errors;
|
|
398
|
+
scenarioWarnings = result.warnings || [];
|
|
399
|
+
scenarios = result.scenarios;
|
|
234
400
|
}
|
|
235
401
|
|
|
236
402
|
// 4. Call verifier — the law engine makes all decisions
|
|
@@ -238,7 +404,8 @@ export async function ingest(submission, options) {
|
|
|
238
404
|
globalPolicy,
|
|
239
405
|
repoPolicy,
|
|
240
406
|
provenance,
|
|
241
|
-
policyVersion
|
|
407
|
+
policyVersion,
|
|
408
|
+
scenarios
|
|
242
409
|
});
|
|
243
410
|
|
|
244
411
|
logStage('verify_complete', {
|
|
@@ -246,14 +413,25 @@ export async function ingest(submission, options) {
|
|
|
246
413
|
correlation_id,
|
|
247
414
|
status: record.verification?.status ?? null,
|
|
248
415
|
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
249
|
-
verdict: record.overall_verdict?.verified ?? null
|
|
416
|
+
verdict: record.overall_verdict?.verified ?? null,
|
|
417
|
+
// F-41872706: the scenario_enforcement event (CLI wrapper, pre-load) logs
|
|
418
|
+
// scenario_count = how many scenarios the submission DECLARED. These three
|
|
419
|
+
// are the post-load breakdown so an operator can tell "N gates enforced"
|
|
420
|
+
// from "N scenarios, all true-404, enforcement skipped". scenarios_loaded
|
|
421
|
+
// is the Map size (definitions actually fetched); required_steps_gates_fired
|
|
422
|
+
// is the subset that carried a non-empty required_steps.
|
|
423
|
+
scenarios_loaded: scenarios ? scenarios.size : 0,
|
|
424
|
+
required_steps_gates_fired: countRequiredStepsGates(scenarios),
|
|
425
|
+
scenarios_warned: scenarioWarnings.length,
|
|
426
|
+
scenarios_errored: scenarioErrors.length
|
|
250
427
|
});
|
|
251
428
|
|
|
252
|
-
// 4b.
|
|
429
|
+
// 4b. Surface scenario loading outcomes: warnings (true-404 definitions) go
|
|
430
|
+
// to verification.warnings; errors (malformed committed files) become
|
|
431
|
+
// scenario-load rejections, collapsed past the reason ceiling (F-2750c4e8).
|
|
432
|
+
appendScenarioWarnings(record, scenarioWarnings);
|
|
253
433
|
if (scenarioErrors.length > 0) {
|
|
254
|
-
record.verification.rejection_reasons.push(
|
|
255
|
-
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
256
|
-
);
|
|
434
|
+
record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
|
|
257
435
|
// If scenario loading failed, this is a rejection
|
|
258
436
|
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
259
437
|
record.verification.status = 'rejected';
|
|
@@ -417,11 +595,18 @@ export async function verifyOnly(submission, options) {
|
|
|
417
595
|
repo_policy_present: !!repoPolicy
|
|
418
596
|
});
|
|
419
597
|
|
|
420
|
-
// 3. Load scenario definitions
|
|
598
|
+
// 3. Load scenario definitions — same gates + semantics as ingest() step 3
|
|
599
|
+
// (F-3bfc2885 Map pass-through, F-efe4f893 Array.isArray gate, wave-4
|
|
600
|
+
// ruling's warning/rejection/operational split), so verify-only and real
|
|
601
|
+
// ingest produce identical records for the same submission.
|
|
421
602
|
let scenarioErrors = [];
|
|
422
|
-
|
|
603
|
+
let scenarioWarnings = [];
|
|
604
|
+
let scenarios = null;
|
|
605
|
+
if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
|
|
423
606
|
const result = await loadScenarios(submission, scenarioFetcher);
|
|
424
607
|
scenarioErrors = result.errors;
|
|
608
|
+
scenarioWarnings = result.warnings || [];
|
|
609
|
+
scenarios = result.scenarios;
|
|
425
610
|
}
|
|
426
611
|
|
|
427
612
|
// 4. Call verifier
|
|
@@ -429,7 +614,8 @@ export async function verifyOnly(submission, options) {
|
|
|
429
614
|
globalPolicy,
|
|
430
615
|
repoPolicy,
|
|
431
616
|
provenance,
|
|
432
|
-
policyVersion
|
|
617
|
+
policyVersion,
|
|
618
|
+
scenarios
|
|
433
619
|
});
|
|
434
620
|
|
|
435
621
|
logStage('verify_complete', {
|
|
@@ -437,15 +623,21 @@ export async function verifyOnly(submission, options) {
|
|
|
437
623
|
correlation_id,
|
|
438
624
|
status: record.verification?.status ?? null,
|
|
439
625
|
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
440
|
-
verdict: record.overall_verdict?.verified ?? null
|
|
626
|
+
verdict: record.overall_verdict?.verified ?? null,
|
|
627
|
+
// F-41872706: same post-load scenario breakdown as ingest()'s
|
|
628
|
+
// verify_complete, so verify-only and real ingest produce identical NDJSON
|
|
629
|
+
// observability for the same submission.
|
|
630
|
+
scenarios_loaded: scenarios ? scenarios.size : 0,
|
|
631
|
+
required_steps_gates_fired: countRequiredStepsGates(scenarios),
|
|
632
|
+
scenarios_warned: scenarioWarnings.length,
|
|
633
|
+
scenarios_errored: scenarioErrors.length
|
|
441
634
|
});
|
|
442
635
|
|
|
443
636
|
// 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
|
|
444
637
|
// real ingest produce identical records for the same submission.
|
|
638
|
+
appendScenarioWarnings(record, scenarioWarnings);
|
|
445
639
|
if (scenarioErrors.length > 0) {
|
|
446
|
-
record.verification.rejection_reasons.push(
|
|
447
|
-
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
448
|
-
);
|
|
640
|
+
record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
|
|
449
641
|
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
450
642
|
record.verification.status = 'rejected';
|
|
451
643
|
record.verification.policy_valid = false;
|
|
@@ -521,6 +713,43 @@ function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, er
|
|
|
521
713
|
// --- CLI entrypoint ---
|
|
522
714
|
// When run directly, reads submission from stdin or file argument
|
|
523
715
|
|
|
716
|
+
// F-9a65b10c (Stage C humanization): the write-path CLI ships a USAGE block like
|
|
717
|
+
// every other bin (verify/cli.js, report/cli.js, portfolio/generate.js). This is
|
|
718
|
+
// the only operator-facing entry point that writes records, so its reference has
|
|
719
|
+
// to live IN the tool, not scattered across docs.
|
|
720
|
+
const USAGE = `ingest — persist a dogfood submission (verify → policy → provenance → write)
|
|
721
|
+
|
|
722
|
+
USAGE:
|
|
723
|
+
node packages/ingest/run.js --file <path> --provenance=github|stub
|
|
724
|
+
node packages/ingest/run.js --payload '<json>' --provenance=github|stub
|
|
725
|
+
echo '<json>' | node packages/ingest/run.js --provenance=github|stub
|
|
726
|
+
|
|
727
|
+
INPUT (exactly one; stdin used when neither flag is given):
|
|
728
|
+
--file <path> Read the submission JSON from a file.
|
|
729
|
+
--payload <json> Pass the submission JSON inline.
|
|
730
|
+
(stdin) Pipe the submission JSON on stdin.
|
|
731
|
+
|
|
732
|
+
PROVENANCE (required for an ingest):
|
|
733
|
+
--provenance=github Confirm the source run via the GitHub API.
|
|
734
|
+
--provenance=stub No-network local confirm (dry-run / dev only).
|
|
735
|
+
|
|
736
|
+
MODES:
|
|
737
|
+
--verify-only Run the full pipeline WITHOUT writing or rebuilding
|
|
738
|
+
indexes; report where a real ingest WOULD have landed.
|
|
739
|
+
|
|
740
|
+
STANDALONE AUDIT VERBS (no submission, no stdin, no --provenance):
|
|
741
|
+
--verify-chain Verify the append-only integrity ledger (offline).
|
|
742
|
+
--reconcile Also fail on on-disk records missing from the ledger.
|
|
743
|
+
--all Report every independent break instead of the first.
|
|
744
|
+
--anchor-compute Compute + write the next XRPL anchor manifest (offline).
|
|
745
|
+
--anchor-post Compute if needed + post the anchor to XRPL (needs XRPL_SEED).
|
|
746
|
+
--anchor-verify Verify local manifests + run the truncation check (offline).
|
|
747
|
+
|
|
748
|
+
-h, --help Show this help.
|
|
749
|
+
|
|
750
|
+
EXIT CODES:
|
|
751
|
+
0 success 1 integrity/audit break 2 operator error (flags / IO / JSON)`;
|
|
752
|
+
|
|
524
753
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
|
|
525
754
|
|
|
526
755
|
if (isMain) {
|
|
@@ -551,6 +780,7 @@ if (isMain) {
|
|
|
551
780
|
// per-record-independent break and report every break in one pass.
|
|
552
781
|
let reconcileFlag = false;
|
|
553
782
|
let allBreaksFlag = false;
|
|
783
|
+
let helpFlag = false;
|
|
554
784
|
// Anchor verbs (optional, off-by-default, operator-run). --anchor-compute and
|
|
555
785
|
// --anchor-verify are fully offline (never import xrpl); --anchor-post lazily
|
|
556
786
|
// loads the optional xrpl package and needs XRPL_SEED.
|
|
@@ -657,11 +887,20 @@ if (isMain) {
|
|
|
657
887
|
} else if (arg === '--anchor-trusted' && hasValue) {
|
|
658
888
|
// Comma-separated trusted anchor accounts (UNIONed with the bundled list).
|
|
659
889
|
anchorTrustedAccounts = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
|
|
890
|
+
} else if (arg === '-h' || arg === '--help' || arg === '--usage') {
|
|
891
|
+
helpFlag = true;
|
|
660
892
|
} else {
|
|
661
893
|
positionalArgs.push(args[i]);
|
|
662
894
|
}
|
|
663
895
|
}
|
|
664
896
|
|
|
897
|
+
// F-9a65b10c: print USAGE and exit 0 before any input resolution — help must
|
|
898
|
+
// never block on stdin or demand a --provenance flag.
|
|
899
|
+
if (helpFlag) {
|
|
900
|
+
console.log(USAGE);
|
|
901
|
+
process.exit(0);
|
|
902
|
+
}
|
|
903
|
+
|
|
665
904
|
// --verify-chain is a standalone, side-effect-free audit: it reads only the
|
|
666
905
|
// ledger + the record files it references, takes no submission, reads no
|
|
667
906
|
// stdin, and needs no provenance adapter. Handle it BEFORE the stdin read and
|
|
@@ -744,6 +983,19 @@ if (isMain) {
|
|
|
744
983
|
}
|
|
745
984
|
|
|
746
985
|
if (!submissionJson) {
|
|
986
|
+
// F-aa67ea9a (Stage C humanization): guard an interactive stdin. Without a
|
|
987
|
+
// piped payload, `for await (…process.stdin)` blocks forever at a TTY —
|
|
988
|
+
// exactly what a first-run operator who forgot to pipe input types — and
|
|
989
|
+
// looks like a hang. Fail fast with the same empty-state error verify/cli.js
|
|
990
|
+
// gives, naming the concrete input levers.
|
|
991
|
+
if (process.stdin.isTTY) {
|
|
992
|
+
console.error('ERROR: no submission provided');
|
|
993
|
+
console.error(
|
|
994
|
+
" hint: pass --file <path> or --payload '<json>', or pipe JSON on stdin; " +
|
|
995
|
+
'run --help for usage.'
|
|
996
|
+
);
|
|
997
|
+
process.exit(2);
|
|
998
|
+
}
|
|
747
999
|
// Read from stdin
|
|
748
1000
|
const chunks = [];
|
|
749
1001
|
for await (const chunk of process.stdin) {
|
|
@@ -791,7 +1043,7 @@ if (isMain) {
|
|
|
791
1043
|
let provenance;
|
|
792
1044
|
if (provenanceMode === 'stub') {
|
|
793
1045
|
// Structural anti-misuse: stub only allowed outside CI
|
|
794
|
-
if (
|
|
1046
|
+
if (isCI()) {
|
|
795
1047
|
emitCliErrorEvent({
|
|
796
1048
|
failedStage: 'cli_provenance_resolve',
|
|
797
1049
|
correlationId: cliCorrelationId,
|
|
@@ -819,7 +1071,7 @@ if (isMain) {
|
|
|
819
1071
|
process.exit(2);
|
|
820
1072
|
}
|
|
821
1073
|
provenance = resolved.provenance;
|
|
822
|
-
} else if (
|
|
1074
|
+
} else if (isCI()) {
|
|
823
1075
|
// In CI without an explicit flag: default to real provenance, routed by the
|
|
824
1076
|
// submission's source.provider (github | gitlab).
|
|
825
1077
|
const resolved = resolveProviderProvenance(submission);
|
|
@@ -845,6 +1097,34 @@ if (isMain) {
|
|
|
845
1097
|
process.exit(2);
|
|
846
1098
|
}
|
|
847
1099
|
|
|
1100
|
+
// F-3bfc2885: construct a scenario fetcher for the production path. Before
|
|
1101
|
+
// this, the CLI never passed one, so scenario definitions — and with them the
|
|
1102
|
+
// `step-results-present` / `step-verdict-consistent` required_steps gates —
|
|
1103
|
+
// were test-only: an operator-declared severity:reject rule silently passed.
|
|
1104
|
+
// Real (non-stub) provenance + a github-provider submission loads scenario
|
|
1105
|
+
// definitions from the source repo at the persisted commit, reusing the token
|
|
1106
|
+
// resolveProviderProvenance already validated. GitLab submissions have no
|
|
1107
|
+
// scenario fetcher yet (no gitlab contents-API adapter exists); their
|
|
1108
|
+
// required_steps gate remains unenforced — a known, documented gap.
|
|
1109
|
+
// V2-INVARIAN-004: the eligibility condition is the exported, unit-tested
|
|
1110
|
+
// resolveScenarioFetcherDecision above.
|
|
1111
|
+
// F-b04473d5: log the decision — an operator auditing an accepted record
|
|
1112
|
+
// must be able to tell "required_steps enforced" from "enforcement skipped
|
|
1113
|
+
// (and why)" straight from the NDJSON stream.
|
|
1114
|
+
const scenarioDecision = resolveScenarioFetcherDecision(provenance, submission, process.env);
|
|
1115
|
+
const scenarioFetcher = scenarioDecision.fetcher;
|
|
1116
|
+
logStage('scenario_enforcement', {
|
|
1117
|
+
submission_id: submission && typeof submission === 'object' && !Array.isArray(submission)
|
|
1118
|
+
? (submission.run_id || null)
|
|
1119
|
+
: null,
|
|
1120
|
+
correlation_id: cliCorrelationId,
|
|
1121
|
+
active: scenarioDecision.active,
|
|
1122
|
+
...(scenarioDecision.reason ? { reason: scenarioDecision.reason } : {}),
|
|
1123
|
+
scenario_count: submission && typeof submission === 'object' && Array.isArray(submission.scenario_results)
|
|
1124
|
+
? submission.scenario_results.length
|
|
1125
|
+
: 0
|
|
1126
|
+
});
|
|
1127
|
+
|
|
848
1128
|
// D1B-001: track the last successful pipeline stage so the outer catch
|
|
849
1129
|
// can surface a useful `failed_stage` in its structured error event.
|
|
850
1130
|
// We update it once the verify/ingest call has RETURNED — anything
|
|
@@ -856,7 +1136,7 @@ if (isMain) {
|
|
|
856
1136
|
// when they could.
|
|
857
1137
|
try {
|
|
858
1138
|
if (verifyOnlyFlag) {
|
|
859
|
-
const result = await verifyOnly(submission, { repoRoot, provenance });
|
|
1139
|
+
const result = await verifyOnly(submission, { repoRoot, provenance, scenarioFetcher });
|
|
860
1140
|
lastSuccessfulStage = 'verify_only';
|
|
861
1141
|
|
|
862
1142
|
console.log(JSON.stringify({
|
|
@@ -877,7 +1157,7 @@ if (isMain) {
|
|
|
877
1157
|
process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
|
|
878
1158
|
}
|
|
879
1159
|
|
|
880
|
-
const result = await ingest(submission, { repoRoot, provenance });
|
|
1160
|
+
const result = await ingest(submission, { repoRoot, provenance, scenarioFetcher });
|
|
881
1161
|
lastSuccessfulStage = 'ingest';
|
|
882
1162
|
|
|
883
1163
|
if (result.duplicate) {
|