@dogfood-lab/ingest 1.8.0 → 1.10.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/integrity.js CHANGED
@@ -62,13 +62,27 @@ export function canonicalize(value) {
62
62
  /**
63
63
  * Deep clone with object keys sorted ascending at every depth. Arrays keep
64
64
  * order (only their element objects are key-sorted). Primitives pass through.
65
+ *
66
+ * F-755d0f3f: the accumulator MUST be null-prototype. On a plain `{}` the
67
+ * assignment below would hit the inherited `Object.prototype.__proto__` setter
68
+ * for an own `__proto__` key and retarget the accumulator's prototype instead of
69
+ * creating an own key — dropping that field, and everything under it, from the
70
+ * canonical string and therefore from the record digest. `JSON.parse` creates a
71
+ * real own `__proto__` data property (CreateDataProperty, not Set), so any
72
+ * record file on disk can carry one, and verify-chain.js reaches this via a bare
73
+ * `JSON.parse` with no schema gate. That made two materially different records
74
+ * hash identically and let a tampered record re-verify clean.
75
+ *
76
+ * `JSON.stringify` serializes a null-prototype object identically to a plain
77
+ * one, so this is byte-for-byte compatible with every already-persisted record
78
+ * and the committed chain is unaffected.
65
79
  */
66
80
  function sortDeep(value) {
67
81
  if (Array.isArray(value)) {
68
82
  return value.map(sortDeep);
69
83
  }
70
84
  if (value !== null && typeof value === 'object') {
71
- const sorted = {};
85
+ const sorted = Object.create(null);
72
86
  for (const key of Object.keys(value).sort()) {
73
87
  sorted[key] = sortDeep(value[key]);
74
88
  }
@@ -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 (`..`) and any path separator
10
- * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
- * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
- * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
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 `/\.\.|[/\\]/` has
16
- * stood since wave 9; this helper is the productized form.
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
- const [org, repo] = repoSlug.split('/');
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
 
@@ -173,24 +180,6 @@ export function loadRepoPolicy(repoSlug, repoRoot) {
173
180
  return parsed;
174
181
  }
175
182
 
176
- /**
177
- * Default scenario fetcher that reads from the local filesystem.
178
- * Used when dogfood-labs is dogfooding itself.
179
- *
180
- * @param {string} repoRoot - Root of the source repo
181
- * @returns {object} Scenario fetch adapter
182
- */
183
- export function localScenarioFetcher(repoRoot) {
184
- return {
185
- async fetch(scenarioId) {
186
- if (!/^[\w-]+$/.test(scenarioId)) return null;
187
- const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
188
- if (!existsSync(path)) return null;
189
- return yaml.load(readFileSync(path, 'utf-8'));
190
- }
191
- };
192
- }
193
-
194
183
  /**
195
184
  * Default per-request timeout for the GitHub scenario fetch. Mirrors the
196
185
  * sibling `GITHUB_PROVENANCE_TIMEOUT_MS` constant at
@@ -215,11 +204,113 @@ export const GITHUB_SCENARIO_FETCH_ATTEMPTS = 3;
215
204
  const RETRY_BASE_MS = 250;
216
205
  const RETRY_MAX_MS = 2000;
217
206
 
207
+ /**
208
+ * V2-CROSS-BO-002: byte cap on a fetched scenario body. A scenario definition
209
+ * crosses the consumer-repo → verifier trust boundary; without a cap a
210
+ * hostile/misconfigured source repo could stream an arbitrarily large body
211
+ * into memory and into yaml.load. Real scenario files are a few KB — 1 MiB is
212
+ * three orders of magnitude of headroom. Checked against Content-Length
213
+ * BEFORE the body is read (when the response exposes it), then enforced
214
+ * WHILE the body streams (F-35486d38: the reader aborts the moment the
215
+ * running byte count crosses the cap, so a header-suppressed oversized body
216
+ * never fully buffers). Responses without a streamable body (test stubs)
217
+ * fall back to text() + a post-read length check, which then bounds only
218
+ * yaml.load, not the buffering step.
219
+ */
220
+ export const GITHUB_SCENARIO_MAX_BYTES = 1024 * 1024;
221
+
222
+ /**
223
+ * F-2750c4e8: hard ceiling on DISTINCT scenario ids fetched per submission.
224
+ * Matches the submission schema's `scenario_results` maxItems (1000) — the
225
+ * published contract bound — so a schema-valid submission can never hit it,
226
+ * while a hostile pre-schema-gate payload (the fetch loop runs before
227
+ * verify()'s schema rejection lands) cannot drive unbounded sequential
228
+ * authenticated GitHub API calls. Combined with the attempted-id dedupe in
229
+ * loadScenarios, the worst-case network spend per submission is bounded by
230
+ * this constant regardless of payload shape.
231
+ */
232
+ export const MAX_DISTINCT_SCENARIO_FETCHES = 1000;
233
+
234
+ /**
235
+ * V2-CROSS-BO-001: classified operational fault for the scenario fetch —
236
+ * mirrors the F-dac7e08c adapter discipline in
237
+ * `packages/verify/validators/provenance.js` (transport rejects and non-404
238
+ * provider statuses THROW after the bounded retry, they never masquerade as
239
+ * "the file does not exist"). The `scenario-fetch-fault:` prefix is
240
+ * registered in `@dogfood-lab/verify`'s parseRejectionReason as
241
+ * 'operational', so any surface that stringifies this error routes the
242
+ * incident to ops instead of bouncing a good submission back to the
243
+ * submitter.
244
+ */
245
+ function scenarioFetchFault(detail) {
246
+ const err = new Error(`scenario-fetch-fault: ${detail}`);
247
+ err.code = 'SCENARIO_FETCH_FAULT';
248
+ return err;
249
+ }
250
+
218
251
  /** Default async backoff. Injectable (`opts.sleepImpl`) so tests run instantly. */
219
252
  function defaultSleep(ms) {
220
253
  return new Promise((r) => setTimeout(r, ms));
221
254
  }
222
255
 
256
+ /**
257
+ * COORD-001: parse a scenario body fetched from a consumer's source repo —
258
+ * the one `yaml.load` call site in this file that sees content a hostile or
259
+ * compromised submitter controls end to end. js-yaml 4.1.1 (the workspace
260
+ * floor; see package.json `"js-yaml": "^4.1.0"`) carries
261
+ * GHSA-h67p-54hq-rp68: chained `<<` merge keys cost O(depth) per level,
262
+ * because `mergeMappings()` (js-yaml lib/loader.js:304-321) re-copies
263
+ * `Object.keys(source)` for the FULL accumulated mapping at every level —
264
+ * so an n-level merge chain costs O(n^2) total. The attack is small BY
265
+ * CONSTRUCTION (each level adds only a couple of YAML lines), so
266
+ * GITHUB_SCENARIO_MAX_BYTES (a 1 MiB cap on the wire size) does not defend
267
+ * it: measured on the reference rig, a chain sized right at that 1 MiB cap
268
+ * costs ~61s of CPU, and scaling is ~quadratic (68 KB -> 127ms, 145 KB ->
269
+ * 656ms). Bumping js-yaml to v5 (which drops merge-key support outright)
270
+ * is blocked — Dependabot #50 is held open because v5 breaks two scripts/
271
+ * gates — so the mitigation lives here instead of in the dependency.
272
+ *
273
+ * CORE_SCHEMA is DEFAULT_SCHEMA minus exactly the YAML-1.1 extras
274
+ * (`timestamp`, `merge`, `binary`, `omap`, `pairs`, `set` — see js-yaml
275
+ * lib/schema/default.js). Without the registered `merge` type, the
276
+ * loader's `keyTag === 'tag:yaml.org,2002:merge'` branch in
277
+ * storeMappingPair() never matches a `<<` key, so `mergeMappings()` is
278
+ * never invoked and the O(depth) copy loop simply cannot run — `<<`
279
+ * resolves as an ordinary (and, against scenario.schema.json, rejected —
280
+ * `additionalProperties: false`) string key instead. This is verified
281
+ * behaviourally, not assumed from the option name, in
282
+ * coord-001-scenario-merge-bomb.test.js: a probe document proves the
283
+ * merge-key never fires, and a second probe proves the real fetch path
284
+ * (not just this helper in isolation) stays protected. Every field
285
+ * scenario.schema.json declares is a plain string/object/array/boolean —
286
+ * none relies on the dropped timestamp/binary/omap/pairs/set types — so
287
+ * this is a pure security hardening with no behavioural cost to a
288
+ * conforming scenario document.
289
+ *
290
+ * Scoped deliberately to THIS call site only. The other two `yaml.load`
291
+ * calls in this file (loadGlobalPolicy, loadRepoPolicy) both read
292
+ * maintainer-committed local files — the attacker does not control their
293
+ * content, only (in loadRepoPolicy's case) which existing file gets
294
+ * selected, and that selection is already segment-validated above.
295
+ * Widening this schema change to those sites is unnecessary risk for zero
296
+ * additional coverage.
297
+ *
298
+ * F-3be85850: this comment previously also named a THIRD "local files"
299
+ * call site, localScenarioFetcher — removed as dead code (zero callers
300
+ * anywhere in the repo, and not reachable even as an external package hook:
301
+ * load-context.js is not in @dogfood-lab/ingest's package.json `exports`
302
+ * map). Its own doc comment claimed it was "Used when dogfood-labs is
303
+ * dogfooding itself," but the real self-dogfood path (self-dogfood.yml)
304
+ * routes through the SAME public ingest.yml repository_dispatch pipeline
305
+ * every consumer uses, via githubScenarioFetcher below — never a local-disk
306
+ * reader. dogfood/scenarios/*.yaml remain the canonical scenario
307
+ * definitions; they are simply fetched over the GitHub Contents API rather
308
+ * than off local disk, even for this repo's own CI.
309
+ */
310
+ export function parseUntrustedScenarioYaml(text) {
311
+ return yaml.load(text, { schema: yaml.CORE_SCHEMA });
312
+ }
313
+
223
314
  /**
224
315
  * GitHub scenario fetcher. Loads scenario definitions from a source repo
225
316
  * via the GitHub API at a specific commit SHA.
@@ -231,9 +322,11 @@ function defaultSleep(ms) {
231
322
  * - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
232
323
  * returns `{ scenario, reason }` where `scenario` is the loaded
233
324
  * object on success or `null` on failure, and `reason` is one of
234
- * `'timeout' | 'not_found' | 'parse_error' | 'invalid_id'` on
235
- * failure (absent on success). The reason gives operators a
236
- * pivot key when diagnosing a stale scenario-load chain.
325
+ * `'not_found' | 'parse_error' | 'invalid_id' | 'too_large' |
326
+ * 'schema_invalid'` on failure (absent on success). The reason gives
327
+ * operators a pivot key when diagnosing a stale scenario-load chain.
328
+ * A timeout that survives every retry no longer RETURNS a typed
329
+ * reason — it throws `scenario-fetch-fault:` (F-07ab7f86, see below).
237
330
  *
238
331
  * Both surfaces honour the per-request AbortController timeout
239
332
  * (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
@@ -241,10 +334,23 @@ function defaultSleep(ms) {
241
334
  * INGEST-PROACT-003: each call makes up to `opts.attempts`
242
335
  * (`GITHUB_SCENARIO_FETCH_ATTEMPTS`) tries with exponential backoff, retrying
243
336
  * ONLY the transient classes — request timeout, HTTP 5xx, HTTP 429, and network
244
- * rejects. A 404 (`not_found`), an `invalid_id`, and a `parse_error` are
245
- * DEFINITIVE answers and are returned immediately without a retry (mirrors the
246
- * EPERM/EBUSY-only discipline in `lib/rename-with-retry.js`). The backoff sleep
247
- * is injectable (`opts.sleepImpl`) so tests do not actually wait.
337
+ * rejects. A 404 (`not_found`), an `invalid_id`, a `parse_error`, a
338
+ * `too_large`, and a `schema_invalid` are DEFINITIVE answers and are returned
339
+ * immediately without a retry (mirrors the EPERM/EBUSY-only discipline in
340
+ * `lib/rename-with-retry.js`). The backoff sleep is injectable
341
+ * (`opts.sleepImpl`) so tests do not actually wait.
342
+ *
343
+ * V2-CROSS-BO-001 + F-07ab7f86: exhausting the retry budget on a 5xx/429, a
344
+ * transport reject, OR a per-request timeout, or hitting any other non-404
345
+ * non-ok status (401/403 bad credential), THROWS a `scenario-fetch-fault:`
346
+ * classified error instead of returning a typed reason — an outage, a slow-API
347
+ * window, or a token fault is an OPERATIONAL incident, never evidence that
348
+ * the scenario file is absent. Callers (loadScenarios → run.js) propagate the
349
+ * throw so the CLI exits 2 without persisting a rejected record. The
350
+ * exhausted-timeout fault keeps the literal word 'timeout' in its detail so
351
+ * the D1B-004/L1-007 operator pivot key survives (contract updated wave 4;
352
+ * pre-fix the typed 'timeout' reason became a submission-rejecting
353
+ * `scenario-load:` reason that poisoned the run_id via the duplicate guard).
248
354
  *
249
355
  * @param {string} token - GitHub PAT
250
356
  * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
@@ -258,7 +364,13 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
258
364
  const attempts = opts.attempts ?? GITHUB_SCENARIO_FETCH_ATTEMPTS;
259
365
  const sleep = opts.sleepImpl ?? defaultSleep;
260
366
 
261
- const [org, repo] = repoSlug.split('/');
367
+ // V2-CROSS-BO-003 (F-54e5fde7 family): the submission contract is strictly
368
+ // two-segment. The old 2-way destructure silently dropped a third segment —
369
+ // and worse, the URL below was built from the RAW slug, so `org/repo/extra`
370
+ // reached the authenticated GitHub API verbatim. Fail closed here and build
371
+ // the URL from the VALIDATED segments only.
372
+ const segments = typeof repoSlug === 'string' ? repoSlug.split('/') : [];
373
+ const [org, repo] = segments.length === 2 ? segments : [null, null];
262
374
  // commitSha is interpolated into the authenticated (Bearer-token) GitHub API
263
375
  // URL's `?ref=` — a shape guard (lowercase-hex, 7–40 chars) refuses anything
264
376
  // that could re-target the ref or inject query params, matching how org/repo
@@ -274,11 +386,14 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
274
386
  };
275
387
  }
276
388
 
277
- // One bounded attempt. Returns `{ scenario, reason, retryable }`; the loop
278
- // below decides whether to retry on `retryable`.
389
+ // One bounded attempt. Returns `{ scenario, reason, retryable, fault }`;
390
+ // the loop below retries on `retryable` and THROWS `fault` (when present)
391
+ // once the budget is exhausted — so a transient blip is ridden out, but a
392
+ // real outage surfaces as an operational fault, never as `not_found`.
279
393
  async function attemptOnce(scenarioId) {
280
394
  const path = `dogfood/scenarios/${scenarioId}.yaml`;
281
- const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
395
+ // V2-CROSS-BO-003: built from the validated org/repo, never the raw slug.
396
+ const url = `https://api.github.com/repos/${org}/${repo}/contents/${path}?ref=${commitSha}`;
282
397
 
283
398
  // D1B-004: AbortController-bounded request. Copied from
284
399
  // `packages/verify/validators/provenance.js:80-104`. The 30s default
@@ -298,33 +413,113 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
298
413
  signal: controller.signal
299
414
  });
300
415
  if (!resp.ok) {
301
- // A 5xx server error or a 429 rate-limit is transient retry. Any
302
- // other non-ok (notably 404) is a definitive answer; do not retry.
303
- const retryable = resp.status >= 500 || resp.status === 429;
304
- return { scenario: null, reason: 'not_found', retryable };
416
+ // V2-CROSS-BO-001: only a 404 means "the scenario file is absent"
417
+ // that is the sole not_found. A 5xx / 429 is a transient provider
418
+ // fault: retry, then throw operational on exhaustion. Anything else
419
+ // (401/403 bad credential, unexpected 4xx) is a definitive
420
+ // OPERATIONAL fault: throw immediately, never retried, never
421
+ // misread as a missing file.
422
+ if (resp.status === 404) {
423
+ return { scenario: null, reason: 'not_found', retryable: false };
424
+ }
425
+ const fault = scenarioFetchFault(
426
+ `GitHub API HTTP ${resp.status} loading scenario "${scenarioId}" from ${org}/${repo}`
427
+ );
428
+ if (resp.status >= 500 || resp.status === 429) {
429
+ return { scenario: null, retryable: true, fault };
430
+ }
431
+ throw fault;
432
+ }
433
+
434
+ // V2-CROSS-BO-002: byte cap at the trust boundary. Check the declared
435
+ // Content-Length first (skips even starting an oversized read when the
436
+ // response exposes headers) — the header is attacker-suppliable, so the
437
+ // read below re-enforces the cap on real bytes.
438
+ const declaredLength = typeof resp.headers?.get === 'function'
439
+ ? Number(resp.headers.get('content-length'))
440
+ : NaN;
441
+ if (declaredLength > GITHUB_SCENARIO_MAX_BYTES) {
442
+ return { scenario: null, reason: 'too_large', retryable: false };
443
+ }
444
+ // F-35486d38: stream the body with a running byte count and abort the
445
+ // moment the cap is crossed, so a header-suppressed oversized body is
446
+ // never fully buffered. Responses without a streamable body (test
447
+ // stubs, non-spec fetch impls) fall back to text() + post-read check,
448
+ // which bounds yaml.load but not the buffering step.
449
+ if (resp.body && typeof resp.body.getReader === 'function') {
450
+ const reader = resp.body.getReader();
451
+ const chunks = [];
452
+ let received = 0;
453
+ let overflow = false;
454
+ for (;;) {
455
+ const { done, value } = await reader.read();
456
+ if (done) break;
457
+ received += value.byteLength;
458
+ if (received > GITHUB_SCENARIO_MAX_BYTES) {
459
+ overflow = true;
460
+ // cancel() may reject if the connection is already torn down —
461
+ // the too_large verdict below stands either way.
462
+ await reader.cancel().catch(() => {});
463
+ break;
464
+ }
465
+ chunks.push(value);
466
+ }
467
+ if (overflow) {
468
+ return { scenario: null, reason: 'too_large', retryable: false };
469
+ }
470
+ text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
471
+ } else {
472
+ text = await resp.text();
473
+ if (Buffer.byteLength(text, 'utf8') > GITHUB_SCENARIO_MAX_BYTES) {
474
+ return { scenario: null, reason: 'too_large', retryable: false };
475
+ }
305
476
  }
306
- text = await resp.text();
307
477
  } catch (err) {
478
+ if (err && err.code === 'SCENARIO_FETCH_FAULT') {
479
+ throw err;
480
+ }
308
481
  if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
309
482
  // A timed-out request may succeed on a retry.
310
483
  return { scenario: null, reason: 'timeout', retryable: true };
311
484
  }
312
- // Network reject, DNS failure, etc. — transient; surface as not_found
313
- // for back-compat with the legacy null contract, but allow a retry.
314
- return { scenario: null, reason: 'not_found', retryable: true };
485
+ // V2-CROSS-BO-001: network reject, DNS failure, etc. — transient, so
486
+ // retry; but on exhaustion this is an outage (operational fault), NOT
487
+ // evidence the scenario file is absent.
488
+ return {
489
+ scenario: null,
490
+ retryable: true,
491
+ fault: scenarioFetchFault(
492
+ `network error loading scenario "${scenarioId}" from ${org}/${repo}: ${err && err.message ? err.message : String(err)}`
493
+ )
494
+ };
315
495
  } finally {
316
496
  clearTimeout(timer);
317
497
  }
318
498
 
499
+ let scenario;
319
500
  try {
320
- const scenario = yaml.load(text);
321
- if (!scenario || typeof scenario !== 'object') {
322
- return { scenario: null, reason: 'parse_error', retryable: false };
323
- }
324
- return { scenario };
501
+ // COORD-001: merge-key DoS defense — see parseUntrustedScenarioYaml's
502
+ // doc comment. `text` is untrusted (fetched from the submitter's
503
+ // source repo), so it must never reach plain yaml.load().
504
+ scenario = parseUntrustedScenarioYaml(text);
325
505
  } catch {
326
506
  return { scenario: null, reason: 'parse_error', retryable: false };
327
507
  }
508
+ if (!scenario || typeof scenario !== 'object') {
509
+ return { scenario: null, reason: 'parse_error', retryable: false };
510
+ }
511
+
512
+ // V2-CROSS-BO-002: schema-gate the loaded object before it crosses into
513
+ // verify()'s required-steps enforcement. A parses-but-nonconforming
514
+ // scenario is a SUBMISSION-SIDE fault (the source repo authored it) — it
515
+ // surfaces through loadScenarios as a typed `schema_invalid` reason and
516
+ // becomes a `scenario-load:` rejection at the ingest layer, never a
517
+ // validator fault.
518
+ const validation = validatePayload('scenario', scenario);
519
+ if (!validation.valid) {
520
+ return { scenario: null, reason: 'schema_invalid', retryable: false };
521
+ }
522
+ return { scenario };
328
523
  }
329
524
 
330
525
  async function fetchWithReason(scenarioId) {
@@ -336,7 +531,42 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
336
531
  for (let i = 0; i < attempts; i++) {
337
532
  last = await attemptOnce(scenarioId);
338
533
  if (last.scenario || !last.retryable || i === attempts - 1) break;
339
- await sleep(Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS));
534
+ const waitMs = Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS);
535
+ // F-2a5ddafa: this loop was completely silent on every retry but the
536
+ // last — an operator had zero early-warning signal that the source
537
+ // repo's API was degrading until the retry budget fully exhausted and
538
+ // threw (see the sibling fix in
539
+ // packages/verify/validators/provenance.js's confirm() loops, same
540
+ // finding). 'warn' (not 'error') since the fetch may still succeed on
541
+ // the next attempt.
542
+ logStage('warn', {
543
+ kind: 'scenario_fetch_retry',
544
+ scenario_id: scenarioId,
545
+ attempt: i + 1,
546
+ status_or_reason: last.reason || (last.fault ? last.fault.message : 'unknown'),
547
+ next_backoff_ms: waitMs,
548
+ });
549
+ await sleep(waitMs);
550
+ }
551
+ // V2-CROSS-BO-001: a transient fault that survived every retry is an
552
+ // outage — throw the classified operational error instead of returning a
553
+ // reason the ingest layer would turn into a submission rejection.
554
+ if (last.fault) {
555
+ throw last.fault;
556
+ }
557
+ // F-07ab7f86: timeout EXHAUSTION is outage-shaped too — a sustained
558
+ // slow-API window (every attempt >timeoutMs) is the same operational
559
+ // class as exhausted ECONNREFUSED/5xx, not evidence the submission is
560
+ // bad. Throw the classified fault, keeping the literal word 'timeout'
561
+ // in the detail so the D1B-004/L1-007 operator pivot key survives in
562
+ // the error message. (This deliberately supersedes the earlier pinned
563
+ // contract where exhausted-timeout returned the typed reason and became
564
+ // a `scenario-load:` rejection — that permanently poisoned run_ids
565
+ // during slow-API windows via the _rejected duplicate guard.)
566
+ if (last.reason === 'timeout') {
567
+ throw scenarioFetchFault(
568
+ `timeout after ${attempts} attempt(s) loading scenario "${scenarioId}" from ${org}/${repo}`
569
+ );
340
570
  }
341
571
  // Strip the internal `retryable` flag from the public contract.
342
572
  const { retryable: _drop, ...result } = last;
@@ -357,32 +587,91 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
357
587
  /**
358
588
  * Load all scenario definitions referenced by a submission's scenario_results.
359
589
  *
360
- * D1B-004 / L1-007 (Wave A2 Stage C amend2): when the fetcher exposes
361
- * the typed `fetchWithReason` surface (the canonical
362
- * `githubScenarioFetcher` does; the legacy `localScenarioFetcher` and any
363
- * legacy test stub may not), the discriminated `reason` (`timeout` /
364
- * `not_found` / `parse_error` / `invalid_id`) is propagated into the
365
- * error string so the operator can pivot on the failure class. Otherwise
366
- * we fall back to the legacy `fetch(id)` truthiness contract.
590
+ * F-efe4f893: this function now runs BEFORE the schema gate on untrusted
591
+ * consumer input (the F-3bfc2885 production wiring), so it must be defensive:
592
+ * - a non-array `scenario_results` returns empty-handed (the schema
593
+ * rejection downstream in verify() is the authoritative signal);
594
+ * - an entry that is not a non-null object carrying a string `scenario_id`
595
+ * is NEVER fetched pre-fix, `/^[\w-]+$/.test(undefined)` coerced to the
596
+ * string 'undefined' and issued a real authenticated GitHub API request
597
+ * per malformed entry. It pushes a typed `malformed_entry` error instead.
598
+ *
599
+ * F-2750c4e8: every ATTEMPTED id is cached (successes AND typed failures), so
600
+ * a repeated id — loaded or failed — costs exactly one fetch, and the number
601
+ * of distinct fetches is capped at {@link MAX_DISTINCT_SCENARIO_FETCHES}
602
+ * (the schema's own maxItems bound). Entries beyond the cap get a typed
603
+ * `fetch_cap` error without touching the network. Thrown
604
+ * `scenario-fetch-fault:`s are deliberately NOT cached — they propagate
605
+ * immediately (see below), so outage semantics survive the dedupe.
606
+ *
607
+ * DESIGN RULING (wave 4) — three-way failure split:
608
+ * - true 404 (`not_found`, typed surface only) → NOT a rejection. The fleet
609
+ * is mixed (some consumers commit dogfood/scenarios/, some don't);
610
+ * nothing declared means nothing to enforce. Surfaces on `warnings` so
611
+ * run.js lands it on verification.warnings (the v1.6.0
612
+ * accepted-with-warning channel) and required-steps enforcement is
613
+ * simply skipped for that scenario.
614
+ * - malformed committed file (`parse_error` / `schema_invalid` /
615
+ * `too_large` / `invalid_id`) → `errors` → submission-bad
616
+ * `scenario-load:` rejection (unchanged).
617
+ * - outage (`scenario-fetch-fault:` throw, incl. exhausted timeout) →
618
+ * PROPAGATES deliberately; the CLI's outer catch emits a structured
619
+ * operational error and exits 2 without persisting (V2-CROSS-BO-001).
620
+ *
621
+ * The legacy `fetch(id)` truthiness surface cannot discriminate not_found
622
+ * from a malformed file, so its `null` keeps the historical
623
+ * rejection-on-error behavior.
367
624
  *
368
625
  * @param {object} submission
369
626
  * @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
370
- * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
627
+ * @returns {Promise<{ scenarios: Map<string, object>, errors: string[], warnings: string[] }>}
371
628
  */
372
629
  export async function loadScenarios(submission, scenarioFetcher) {
373
630
  const scenarios = new Map();
374
631
  const errors = [];
632
+ const warnings = [];
633
+
634
+ const results = submission && typeof submission === 'object'
635
+ ? submission.scenario_results
636
+ : undefined;
637
+ if (!Array.isArray(results)) {
638
+ // Non-array shapes (string, object, null) iterate wrong or crash —
639
+ // notably a STRING iterates characters. Skip entirely; verify()'s schema
640
+ // gate rejects the submission with the authoritative reason.
641
+ return { scenarios, errors, warnings };
642
+ }
375
643
 
376
644
  const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
645
+ // F-2750c4e8: id → true once an id has been resolved (success OR typed
646
+ // failure), so repeats never re-fetch. Faults are uncached: they throw.
647
+ const attempted = new Set();
648
+ let distinctFetches = 0;
377
649
 
378
- for (const sr of submission.scenario_results || []) {
650
+ for (const sr of results) {
651
+ if (!sr || typeof sr !== 'object' || Array.isArray(sr) || typeof sr.scenario_id !== 'string') {
652
+ errors.push(
653
+ 'scenario_results entry is not an object with a string scenario_id (reason: malformed_entry)'
654
+ );
655
+ continue;
656
+ }
379
657
  const id = sr.scenario_id;
380
- if (scenarios.has(id)) continue;
658
+ if (attempted.has(id)) continue;
659
+ attempted.add(id);
660
+
661
+ if (distinctFetches >= MAX_DISTINCT_SCENARIO_FETCHES) {
662
+ errors.push(
663
+ `scenario "${id}" not fetched — distinct-scenario fetch cap (${MAX_DISTINCT_SCENARIO_FETCHES}) reached (reason: fetch_cap)`
664
+ );
665
+ continue;
666
+ }
667
+ distinctFetches++;
381
668
 
382
669
  if (supportsTypedReason) {
383
670
  const result = await scenarioFetcher.fetchWithReason(id);
384
671
  if (result && result.scenario) {
385
672
  scenarios.set(id, result.scenario);
673
+ } else if (result && result.reason === 'not_found') {
674
+ warnings.push(`scenario definition not found for "${id}" — required_steps unenforced`);
386
675
  } else {
387
676
  const reason = result && result.reason ? result.reason : 'unknown';
388
677
  errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
@@ -397,5 +686,5 @@ export async function loadScenarios(submission, scenarioFetcher) {
397
686
  }
398
687
  }
399
688
 
400
- return { scenarios, errors };
689
+ return { scenarios, errors, warnings };
401
690
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",