@dogfood-lab/verify 1.4.0 → 1.5.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/README.md CHANGED
@@ -108,8 +108,9 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
108
108
  | `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
109
109
  | `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
110
110
  | `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION:` | `runValidator('contract_schema_version', …)` catch | The version gate was called with an unknown contract key (a programmer error at the call site, not a submission fault). |
111
+ | `submission-malformed:` | `index.js` null/non-object early-return | The submission itself was `null` or not an object — a malfunctioning **dispatcher** sent garbage, not a submitter who authored a bad-but-shaped payload. Page ops / inspect the dispatch pipeline; do NOT bounce it to a submitter. |
111
112
 
112
- Any future `VALIDATOR_FAULT_<NEW>:` prefix is classified `operational` by family — `parseRejectionReason` matches the `VALIDATOR_FAULT_` head, so a new validator class needs no parser edit.
113
+ Any future `VALIDATOR_FAULT_<NEW>:` prefix is classified `operational` by family — `parseRejectionReason` matches the `VALIDATOR_FAULT_` head, so a new validator class needs no parser edit. The `submission-malformed:` prefix is matched literally (it is not part of the `VALIDATOR_FAULT_` family).
113
114
 
114
115
  **Ingest** — `class: 'ingest'` (an ingest-side load fault, not a verifier gate):
115
116
 
package/cli.js ADDED
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify CLI (FT-c) — local dry-run / explain front-end for the verifier.
4
+ *
5
+ * package.json declared a `verify` bin/script pointing at this file long
6
+ * before it existed, so `node cli.js` / `npx @dogfood-lab/verify` failed with
7
+ * MODULE_NOT_FOUND. This is that file: a thin operator front-end over the
8
+ * library's `verify()` + `parseRejectionReason()` — it does NOT reimplement
9
+ * any validation. A consumer who wants to know WHY a submission would pass or
10
+ * fail before pushing it through CI runs:
11
+ *
12
+ * node cli.js --file submission.json --explain
13
+ *
14
+ * and gets a human-readable verdict breakdown, with each rejection reason
15
+ * classified (submission-bad vs operational vs ingest vs unknown) so they know
16
+ * WHOSE problem it is — their payload, the verifier/tooling, or ingest.
17
+ *
18
+ * Why this lives in verify and not ingest: ingest's CLI (packages/ingest/run.js)
19
+ * is the WRITE path — it persists records and rebuilds indexes, requires an
20
+ * explicit provenance adapter, and forbids stub provenance in CI. This CLI is a
21
+ * pure READ/preview path: it never touches the filesystem beyond reading the
22
+ * submission + policy files, defaults to stub provenance for a no-network local
23
+ * check, and exits without side effects. The two share an exit-code contract
24
+ * (0 accepted / 1 rejected / 2 operator error) so a wrapper can reason about
25
+ * both uniformly.
26
+ *
27
+ * Exit codes (consistent with packages/ingest/run.js):
28
+ * 0 — submission accepted
29
+ * 1 — submission rejected (verdict reached; the payload is the problem)
30
+ * 2 — operator error (bad flags, unreadable file, invalid JSON, missing
31
+ * policy) — the consumer must fix their invocation/environment, not the
32
+ * submission.
33
+ */
34
+
35
+ import { readFileSync } from 'node:fs';
36
+ import { resolve, dirname, join } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import yaml from 'js-yaml';
39
+
40
+ import { verify, parseRejectionReason } from './index.js';
41
+ import { stubProvenance, provenanceForProvider } from './validators/provenance.js';
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+
45
+ /**
46
+ * Operator-error sentinel. Thrown for any exit-2 condition (bad flags,
47
+ * unreadable file, invalid JSON, missing/unreadable policy) so the single
48
+ * top-level catch can emit one structured message and exit 2 — distinct from a
49
+ * legitimate rejection (exit 1), which is NOT an error.
50
+ */
51
+ class OperatorError extends Error {
52
+ constructor(message, hint) {
53
+ super(message);
54
+ this.name = 'OperatorError';
55
+ this.hint = hint;
56
+ }
57
+ }
58
+
59
+ const USAGE = `verify — local dry-run / explain for dogfood submissions
60
+
61
+ USAGE:
62
+ verify --file <path> [--explain | --json] [--provenance=stub|github]
63
+ verify --payload '<json>' [--explain | --json] [--provenance=stub|github]
64
+
65
+ INPUT (exactly one required):
66
+ --file <path> Read the submission JSON from a file.
67
+ --payload <json> Pass the submission JSON inline.
68
+
69
+ OUTPUT MODE (default: --explain):
70
+ --explain Human-readable verdict breakdown with each rejection
71
+ reason classified (who must fix it). [default]
72
+ --json Machine-readable result for tooling. Mutually exclusive
73
+ with --explain.
74
+
75
+ PROVENANCE (default: stub):
76
+ --provenance=stub No-network local check; provenance is always confirmed.
77
+ This is a LOCAL DRY-RUN — a real ingest re-checks
78
+ provenance against the source run. [default]
79
+ --provenance=github Confirm the source run via the GitHub API. Requires
80
+ GITHUB_TOKEN or GH_TOKEN in the environment.
81
+
82
+ -h, --help Show this help.
83
+
84
+ EXIT CODES:
85
+ 0 accepted 1 rejected 2 operator error (bad flags / IO / JSON)`;
86
+
87
+ /**
88
+ * Parse argv into a normalized option set. Accepts BOTH `--flag value` and
89
+ * `--flag=value` forms — mirrors the parser in packages/ingest/run.js so the
90
+ * two CLIs feel identical. Throws OperatorError (→ exit 2) on any malformed or
91
+ * conflicting invocation rather than silently picking a default, so a typo'd
92
+ * flag never produces a misleading verdict.
93
+ *
94
+ * @param {string[]} argv - process.argv.slice(2)
95
+ * @returns {{ help: boolean, file: string|null, payload: string|null,
96
+ * mode: 'explain'|'json', provenanceMode: 'stub'|'github' }}
97
+ */
98
+ export function parseArgs(argv) {
99
+ let file = null;
100
+ let payload = null;
101
+ let mode = null; // 'explain' | 'json' — defaulted after parsing
102
+ let provenanceMode = null; // 'stub' | 'github' — defaulted after parsing
103
+ let help = false;
104
+
105
+ for (let i = 0; i < argv.length; i++) {
106
+ let arg = argv[i];
107
+ let inlineValue = null;
108
+ if (arg.startsWith('--')) {
109
+ const eq = arg.indexOf('=');
110
+ if (eq !== -1) {
111
+ inlineValue = arg.slice(eq + 1);
112
+ arg = arg.slice(0, eq);
113
+ }
114
+ }
115
+ const hasValue = inlineValue !== null || argv[i + 1] !== undefined;
116
+ const takeValue = () => (inlineValue !== null ? inlineValue : argv[++i]);
117
+
118
+ switch (arg) {
119
+ case '-h':
120
+ case '--help':
121
+ help = true;
122
+ break;
123
+ case '--file':
124
+ if (!hasValue) throw new OperatorError('--file requires a path', 'verify --file submission.json --explain');
125
+ if (file !== null) throw new OperatorError('--file given more than once');
126
+ file = takeValue();
127
+ break;
128
+ case '--payload':
129
+ if (!hasValue) throw new OperatorError('--payload requires a JSON string', "verify --payload '{...}' --explain");
130
+ if (payload !== null) throw new OperatorError('--payload given more than once');
131
+ payload = takeValue();
132
+ break;
133
+ case '--explain':
134
+ if (mode === 'json') throw new OperatorError('--explain and --json are mutually exclusive');
135
+ mode = 'explain';
136
+ break;
137
+ case '--json':
138
+ if (mode === 'explain') throw new OperatorError('--explain and --json are mutually exclusive');
139
+ mode = 'json';
140
+ break;
141
+ case '--provenance': {
142
+ if (!hasValue) throw new OperatorError('--provenance requires a value', '--provenance=stub or --provenance=github');
143
+ const v = takeValue();
144
+ if (v !== 'stub' && v !== 'github') {
145
+ throw new OperatorError(`unknown --provenance value: ${v}`, 'use --provenance=stub or --provenance=github');
146
+ }
147
+ provenanceMode = v;
148
+ break;
149
+ }
150
+ default:
151
+ throw new OperatorError(`unknown argument: ${argv[i]}`, 'run `verify --help` for usage');
152
+ }
153
+ }
154
+
155
+ if (help) {
156
+ return { help: true, file: null, payload: null, mode: 'explain', provenanceMode: 'stub' };
157
+ }
158
+
159
+ if (file === null && payload === null) {
160
+ throw new OperatorError('no submission provided', 'pass --file <path> or --payload <json>');
161
+ }
162
+ if (file !== null && payload !== null) {
163
+ throw new OperatorError('--file and --payload are mutually exclusive', 'provide exactly one input');
164
+ }
165
+
166
+ return {
167
+ help: false,
168
+ file,
169
+ payload,
170
+ // --explain is the documented default — a consumer running `verify --file x`
171
+ // wants the human breakdown, not raw JSON.
172
+ mode: mode ?? 'explain',
173
+ // stub is the dry-run default: a local check should not require a network
174
+ // round-trip or a GitHub token just to learn whether the PAYLOAD is shaped
175
+ // right. A real ingest still re-confirms provenance for keeps.
176
+ provenanceMode: provenanceMode ?? 'stub'
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Read and JSON-parse the submission from --file or --payload. Both IO and
182
+ * parse failures are OperatorErrors (exit 2): a submission the consumer cannot
183
+ * even hand us is an invocation problem, not a rejected-but-shaped payload.
184
+ *
185
+ * @returns {object} The parsed submission (may be any JSON value — verify()
186
+ * itself handles null/non-object/array inputs and produces a rejection).
187
+ */
188
+ function loadSubmission({ file, payload }) {
189
+ let raw;
190
+ if (file !== null) {
191
+ try {
192
+ raw = readFileSync(resolve(file), 'utf-8');
193
+ } catch (e) {
194
+ throw new OperatorError(`could not read --file: ${resolve(file)} — ${e.message}`,
195
+ 'check the path exists and is readable');
196
+ }
197
+ } else {
198
+ raw = payload;
199
+ }
200
+ try {
201
+ return JSON.parse(raw);
202
+ } catch (e) {
203
+ throw new OperatorError(`invalid JSON submission — ${e.message}`,
204
+ 'the submission must be valid JSON');
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Load the policy set the verifier needs to evaluate this submission.
210
+ *
211
+ * verify is a LEAF package (deps: @dogfood-lab/schemas + js-yaml only); the
212
+ * full policy loader lives in @dogfood-lab/ingest, which sits downstream in the
213
+ * workspace cycle (findings → ingest → dogfood-swarm → findings). Importing it
214
+ * here would add a verify → ingest edge and widen that cycle. Instead this is a
215
+ * deliberately minimal read — the same `yaml.load(readFileSync(...))` the verify
216
+ * test suite uses — kept self-contained so verify stays a leaf. The verifier
217
+ * still owns ALL the policy LOGIC; this only locates and parses the YAML.
218
+ *
219
+ * Global policy is required (a missing one is an operator error → exit 2). Repo
220
+ * policy is optional: absent → null (defaults apply), exactly as the library
221
+ * contract documents.
222
+ *
223
+ * @param {object} submission - used only for submission.repo (repo-policy lookup)
224
+ * @param {string} repoRoot
225
+ * @returns {{ globalPolicy: object, repoPolicy: object|null, policyVersion: string }}
226
+ */
227
+ function loadPolicies(submission, repoRoot) {
228
+ const globalPath = join(repoRoot, 'policies', 'global-policy.yaml');
229
+ let globalPolicy;
230
+ try {
231
+ globalPolicy = yaml.load(readFileSync(globalPath, 'utf-8'));
232
+ } catch (e) {
233
+ throw new OperatorError(`global policy unreadable: ${globalPath} — ${e.message}`,
234
+ 'run from the testing-os repo root, or set VERIFY_REPO_ROOT to it');
235
+ }
236
+
237
+ let repoPolicy = null;
238
+ const repoSlug = submission && typeof submission === 'object' ? submission.repo : null;
239
+ if (typeof repoSlug === 'string' && repoSlug.includes('/')) {
240
+ const [org, repo] = repoSlug.split('/');
241
+ // Reject path-traversal segments before touching the filesystem — a hostile
242
+ // submission.repo like '../../etc' must never escape policies/repos/.
243
+ const safe = (s) => typeof s === 'string' && s.length > 0 && !s.includes('..') && !s.includes('\\') && s !== '.';
244
+ if (safe(org) && safe(repo)) {
245
+ const repoPath = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
246
+ try {
247
+ repoPolicy = yaml.load(readFileSync(repoPath, 'utf-8'));
248
+ } catch {
249
+ // Absent or unreadable repo policy → null (defaults apply). This CLI is
250
+ // a preview; it does not reproduce ingest's torn-policy sentinel. A real
251
+ // ingest is the authority on a corrupt repo-policy file.
252
+ repoPolicy = null;
253
+ }
254
+ }
255
+ }
256
+
257
+ const policyVersion =
258
+ (repoPolicy && repoPolicy.policy_version) ||
259
+ (globalPolicy && globalPolicy.policy_version) ||
260
+ '1.0.0';
261
+
262
+ return { globalPolicy, repoPolicy, policyVersion };
263
+ }
264
+
265
+ /**
266
+ * Resolve the provenance adapter for the requested mode. stub is side-effect-
267
+ * free and offline (the dry-run default); the real mode confirms the source run
268
+ * and is routed by `submission.source.provider` (github | gitlab) through the
269
+ * adapter registry, sourcing the provider's token from the environment (missing
270
+ * token → operator error, exit 2).
271
+ */
272
+ function resolveProvenance(provenanceMode, submission) {
273
+ if (provenanceMode === 'github') {
274
+ const provider = (submission && submission.source && submission.source.provider) || 'github';
275
+ const factory = provenanceForProvider(provider);
276
+ if (!factory) {
277
+ throw new OperatorError(`unknown provenance provider '${provider}' (supported: github, gitlab)`,
278
+ 'check submission.source.provider, or use --provenance=stub for a local dry-run');
279
+ }
280
+ const token = provider === 'gitlab'
281
+ ? (process.env.GITLAB_TOKEN || process.env.CI_JOB_TOKEN)
282
+ : (process.env.GITHUB_TOKEN || process.env.GH_TOKEN);
283
+ if (!token) {
284
+ const need = provider === 'gitlab' ? 'GITLAB_TOKEN or CI_JOB_TOKEN' : 'GITHUB_TOKEN or GH_TOKEN';
285
+ throw new OperatorError(`real provenance for provider '${provider}' requires ${need}`,
286
+ 'export a token with read access to the CI run, or use --provenance=stub for a local dry-run');
287
+ }
288
+ return factory(token);
289
+ }
290
+ return stubProvenance;
291
+ }
292
+
293
+ /**
294
+ * Human-readable label + one-line fix hint per rejection class. The class is
295
+ * the routing decision parseRejectionReason() makes; this maps it to operator
296
+ * language so the consumer knows WHOSE problem each reason is.
297
+ */
298
+ const CLASS_LABEL = {
299
+ 'submission-bad': 'SUBMISSION — fix your payload and resubmit',
300
+ 'operational': 'OPERATIONAL — verifier/tooling fault; page ops, do not bounce to submitter',
301
+ 'ingest': 'INGEST — ingest-side load fault (scenario fetch)',
302
+ 'unknown': 'UNKNOWN — unrecognized reason; log and inspect raw'
303
+ };
304
+
305
+ /**
306
+ * Render the explain (human) view of a verification result. Groups rejection
307
+ * reasons by class so the consumer can see at a glance how many of their
308
+ * failures are their own payload vs the verifier vs ingest.
309
+ *
310
+ * @param {object} record - the persisted record returned by verify()
311
+ * @returns {string}
312
+ */
313
+ export function renderExplain(record) {
314
+ const v = record.verification || {};
315
+ const reasons = v.rejection_reasons || [];
316
+ const accepted = v.status === 'accepted';
317
+ const lines = [];
318
+
319
+ lines.push(accepted ? 'VERDICT: accepted' : 'VERDICT: rejected');
320
+ lines.push('');
321
+ lines.push(` schema_valid: ${v.schema_valid}`);
322
+ lines.push(` policy_valid: ${v.policy_valid}`);
323
+ lines.push(` provenance_confirmed: ${v.provenance_confirmed}`);
324
+ if (record.overall_verdict) {
325
+ const ov = record.overall_verdict;
326
+ const downgraded = ov.downgraded ? ` (downgraded from ${ov.proposed ?? 'null'})` : '';
327
+ lines.push(` verdict: ${ov.verified ?? 'null'}${downgraded}`);
328
+ }
329
+
330
+ if (accepted) {
331
+ lines.push('');
332
+ lines.push('No rejection reasons. This submission would be accepted.');
333
+ return lines.join('\n');
334
+ }
335
+
336
+ // Group reasons by class so the breakdown reads as "here is who must act,"
337
+ // not a flat undifferentiated list. parseRejectionReason owns the taxonomy.
338
+ const byClass = new Map();
339
+ for (const reason of reasons) {
340
+ const parsed = parseRejectionReason(reason);
341
+ if (!byClass.has(parsed.class)) byClass.set(parsed.class, []);
342
+ byClass.get(parsed.class).push(parsed);
343
+ }
344
+
345
+ lines.push('');
346
+ lines.push(`REJECTION REASONS (${reasons.length}):`);
347
+ // Stable, operator-meaningful order: payload problems first (most actionable
348
+ // by the consumer), then operational, ingest, unknown.
349
+ const ORDER = ['submission-bad', 'operational', 'ingest', 'unknown'];
350
+ for (const cls of ORDER) {
351
+ const items = byClass.get(cls);
352
+ if (!items || items.length === 0) continue;
353
+ lines.push('');
354
+ lines.push(` [${CLASS_LABEL[cls]}]`);
355
+ for (const parsed of items) {
356
+ const prefix = parsed.prefix ? `${parsed.prefix} ` : '';
357
+ lines.push(` - ${prefix}${parsed.detail}`);
358
+ }
359
+ }
360
+ return lines.join('\n');
361
+ }
362
+
363
+ /**
364
+ * Build the machine-readable (--json) result. Each reason is pre-classified so
365
+ * tooling never has to re-implement parseRejectionReason at the call site.
366
+ *
367
+ * @param {object} record
368
+ * @returns {object}
369
+ */
370
+ export function buildJsonResult(record) {
371
+ const v = record.verification || {};
372
+ const reasons = v.rejection_reasons || [];
373
+ return {
374
+ status: v.status ?? null,
375
+ run_id: record.run_id ?? null,
376
+ verdict: record.overall_verdict?.verified ?? null,
377
+ schema_valid: v.schema_valid ?? null,
378
+ policy_valid: v.policy_valid ?? null,
379
+ provenance_confirmed: v.provenance_confirmed ?? null,
380
+ rejection_reasons: reasons.map(reason => {
381
+ const parsed = parseRejectionReason(reason);
382
+ return { class: parsed.class, prefix: parsed.prefix, detail: parsed.detail, raw: reason };
383
+ })
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Run the CLI. Returns the process exit code instead of calling process.exit,
389
+ * so the function is unit-testable (a test drives it with argv + an injected
390
+ * stdout sink and asserts on the code + output).
391
+ *
392
+ * @param {string[]} argv - process.argv.slice(2)
393
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void, repoRoot?: string }} [io]
394
+ * @returns {Promise<number>} exit code (0 accepted / 1 rejected / 2 operator error)
395
+ */
396
+ export async function run(argv, io = {}) {
397
+ const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
398
+ const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
399
+ const repoRoot = io.repoRoot
400
+ ?? (process.env.VERIFY_REPO_ROOT ? resolve(process.env.VERIFY_REPO_ROOT) : resolve(__dirname, '../..'));
401
+
402
+ let opts;
403
+ try {
404
+ opts = parseArgs(argv);
405
+ } catch (e) {
406
+ err(`ERROR: ${e.message}`);
407
+ if (e.hint) err(` hint: ${e.hint}`);
408
+ return 2;
409
+ }
410
+
411
+ if (opts.help) {
412
+ out(USAGE);
413
+ return 0;
414
+ }
415
+
416
+ let record;
417
+ try {
418
+ const submission = loadSubmission(opts);
419
+ const { globalPolicy, repoPolicy, policyVersion } = loadPolicies(submission, repoRoot);
420
+ const provenance = resolveProvenance(opts.provenanceMode, submission);
421
+ record = await verify(submission, { globalPolicy, repoPolicy, provenance, policyVersion });
422
+ } catch (e) {
423
+ // OperatorError → exit 2 with a hint. Anything else thrown here is an
424
+ // unexpected fault in the preview path; surface it as exit 2 too (the
425
+ // consumer cannot fix a payload we never managed to verify).
426
+ err(`ERROR: ${e.message}`);
427
+ if (e.hint) err(` hint: ${e.hint}`);
428
+ return 2;
429
+ }
430
+
431
+ if (opts.mode === 'json') {
432
+ out(JSON.stringify(buildJsonResult(record)));
433
+ } else {
434
+ out(renderExplain(record));
435
+ }
436
+
437
+ return record.verification?.status === 'accepted' ? 0 : 1;
438
+ }
439
+
440
+ // --- CLI entrypoint ---
441
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'cli.js');
442
+ if (isMain) {
443
+ run(process.argv.slice(2)).then((code) => process.exit(code));
444
+ }
package/index.js CHANGED
@@ -11,6 +11,7 @@ import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js
11
11
  import { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
12
12
  import { validateSchemaVersion as _defaultValidateSchemaVersion } from './validators/schema-version.js';
13
13
  import { computeVerdict } from './validators/verdict.js';
14
+ import { parseRunUrlRepo } from './validators/repo-binding.js';
14
15
  import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
15
16
 
16
17
  // F1-CONTRACTS-003: re-export the rejection-reason classifier from the package
@@ -18,6 +19,14 @@ import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
18
19
  // instead of hand-rolling .startsWith() chains over the prefix taxonomy.
19
20
  export { parseRejectionReason } from './parse-rejection.js';
20
21
 
22
+ // Provider-keyed provenance selection. A submission's `source.provider` decides
23
+ // which provider API confirms its run; `provenanceForProvider(provider)` returns
24
+ // the matching adapter factory (or null for an unknown provider). Re-exported
25
+ // from the package root so the wiring layer selects by provider here rather than
26
+ // hand-rolling an if-chain over provider literals. The registry stays in lockstep
27
+ // with the source.provider enum via validators/provenance-registry.test.js.
28
+ export { provenanceForProvider, PROVENANCE_ADAPTERS } from './validators/provenance.js';
29
+
21
30
  // F1-CONTRACTS-001: the persisted record's `schema_version` is the SINGLE
22
31
  // source of truth from the contract package — not a hardcoded literal that
23
32
  // can drift from `SUPPORTED_SCHEMA_VERSIONS.record.current`.
@@ -95,7 +104,12 @@ export async function verify(submission, options) {
95
104
  provenance_confirmed: false,
96
105
  schema_valid: false,
97
106
  policy_valid: false,
98
- rejection_reasons: ['submission is null or not an object']
107
+ // verify-B-003: a null/non-object submission is a malfunctioning
108
+ // DISPATCHER (the caller handed us garbage), not a submitter who sent a
109
+ // bad-but-shaped payload. Carry a typed `submission-malformed:` prefix so
110
+ // parseRejectionReason classifies it 'operational' (page the runner) instead
111
+ // of bouncing an ops incident back to the submitter as 'unknown'.
112
+ rejection_reasons: ['submission-malformed: submission is null or not an object']
99
113
  }
100
114
  };
101
115
  }
@@ -117,13 +131,17 @@ export async function verify(submission, options) {
117
131
  // real, legitimate run from their own repo. Provenance would confirm (the run
118
132
  // exists), and the persist layer would file the record under victim-org's path
119
133
  // — a forged "pass" verdict for a repo the submitter does not control.
120
- // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
134
+ //
135
+ // verify-B-001: the run_url shape is PROVIDER-specific, so the decode is keyed
136
+ // by `source.provider` in validators/repo-binding.js. RUN_URL_PARSERS there MUST
137
+ // stay in lockstep with the source.provider enum in the submission schema — a
138
+ // coverage test (validators/repo-binding.test.js) fails CI if a provider is
139
+ // added to the schema without a parser, so this binding can never silently
140
+ // no-op for a new provider (which would reopen the verify-A-001 forgery vector).
121
141
  if (submission.repo && submission.source?.run_url) {
122
- const m = submission.source.run_url.match(
123
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
124
- );
125
- if (m) {
126
- const sourceRepo = `${m[1]}/${m[2]}`;
142
+ const bound = parseRunUrlRepo(submission.source.provider, submission.source.run_url);
143
+ if (bound) {
144
+ const sourceRepo = `${bound.owner}/${bound.repo}`;
127
145
  if (sourceRepo !== submission.repo) {
128
146
  reasons.push(
129
147
  `repo:mismatch: submission.repo (${submission.repo}) does not match source.run_url repo (${sourceRepo})`
@@ -182,7 +200,14 @@ export async function verify(submission, options) {
182
200
  let provenanceConfirmed = false;
183
201
  if (schemaResult.valid && submission.source) {
184
202
  try {
185
- provenanceConfirmed = await provenance.confirm(submission.source);
203
+ // verify-A-001: bind the PERSISTED commit (submission.ref.commit_sha) to the
204
+ // verified run. The record attests to this commit, so the provenance adapter
205
+ // must reject a run whose head differs from it — otherwise a real run can be
206
+ // pointed at an arbitrary persisted sha. Adapters that ignore the binding
207
+ // (stub/rejecting) accept the extra arg harmlessly.
208
+ provenanceConfirmed = await provenance.confirm(submission.source, {
209
+ refCommitSha: submission.ref?.commit_sha
210
+ });
186
211
  } catch (err) {
187
212
  reasons.push(`provenance: verification failed: ${err.message}`);
188
213
  }
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
6
6
  "main": "index.js",
7
+ "bin": {
8
+ "dogfood-verify": "cli.js"
9
+ },
7
10
  "exports": {
8
11
  ".": "./index.js",
12
+ "./cli.js": "./cli.js",
13
+ "./parse-rejection.js": "./parse-rejection.js",
9
14
  "./validators/*": "./validators/*",
10
15
  "./validators/*.js": "./validators/*.js"
11
16
  },
@@ -15,6 +20,8 @@
15
20
  },
16
21
  "files": [
17
22
  "index.js",
23
+ "cli.js",
24
+ "parse-rejection.js",
18
25
  "validators/",
19
26
  "README.md",
20
27
  "LICENSE"