@dogfood-lab/verify 1.4.0 → 1.6.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
@@ -83,7 +83,7 @@ Provenance fields (`github_run_id`, `github_workflow_ref`) are required when `pr
83
83
 
84
84
  ### Prefix taxonomy
85
85
 
86
- The verifier emits two prefix classes:
86
+ The verifier emits rejection-reason strings under stable prefixes, each mapping to one of four routing classes:
87
87
 
88
88
  Discrimination happens by **class**, surfaced by `parseRejectionReason` (below). Every prefix maps to one of four classes: **submission-bad** (the submitter fixes the payload), **operational** (the verifier/tooling faulted), **ingest** (an ingest-side load fault), or **unknown** (unrecognized prefix).
89
89
 
@@ -94,7 +94,7 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
94
94
  | `schema:` | `validators/schema.js` | JSON Schema check on the submission/record envelope failed. The rest of the string carries the AJV path + message. |
95
95
  | `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, etc.). |
96
96
  | `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
97
- | `provenance:` | `validators/provenance.js` | The GitHub run-id confirmation could not match the submitted commit/repo at the GitHub API. |
97
+ | `provenance:` | `validators/provenance.js` | The run was genuinely **absent / not confirmable** — a 404 from the provider API, or the run head did not match the submitted commit/repo. The submitter's payload points at a run that does not exist or does not bind. (Operational provider faults — 429/5xx/401/403 — are NOT this class; see `provenance-fault:` below.) |
98
98
  | `repo:` | `index.js` cross-field guard | `submission.repo` does not match the owner/repo encoded in `source.run_url` (anti-forgery guard). Emitted as `repo:mismatch: …`. |
99
99
  | `submission-contains-verifier-field:` | `index.js` | The submission carried a verifier-owned field (`policy_version`, `verification`, or an object `overall_verdict`) it must not author. |
100
100
  | `CONTRACT_SCHEMA_TOO_NEW:` | `validators/schema-version.js` | The submission's `schema_version` declares a MAJOR **above** what this build supports (see `SUPPORTED_SCHEMA_VERSIONS` in `@dogfood-lab/schemas`). This build cannot understand a future contract — **the operator must upgrade testing-os**, but the routing class stays submission-bad (the payload as-shipped cannot be accepted by THIS build). |
@@ -108,8 +108,10 @@ 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. |
112
+ | `provenance-fault:` | `index.js` provenance catch | The provenance adapter THREW an operational error confirming the run — a provider **429 rate-limit, 5xx outage, or 401/403 token** fault (`validators/provenance.js` throws these on purpose for non-404 responses). The submitter's payload is fine; the verifier could not reach a verdict. Page ops / retry; do NOT bounce it to a submitter. Distinct from the submission-bad `provenance:` (genuine absence/404). |
111
113
 
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.
114
+ 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
115
 
114
116
  **Ingest** — `class: 'ingest'` (an ingest-side load fault, not a verifier gate):
115
117
 
@@ -152,6 +154,10 @@ for (const r of result.rejection_reasons) {
152
154
 
153
155
  Persistence note: every entry above is round-tripped verbatim through `verification.rejection_reasons` in the persisted-record JSON; the schema enforces `array of string` so any consumer of the audit-DB ground truth sees the same prefix vocabulary.
154
156
 
157
+ ### Warnings channel (accepted-with-warning)
158
+
159
+ Not every policy signal is a rejection. A policy rule declared `severity: warn` produces a `policy: <id>: <message>` entry on `verification.warnings` (an optional `array of string` on the persisted record) **without** flipping the verdict to `rejected` — the submission is accepted and recorded, the warning rides alongside it. (`severity: info` rules are logged only and never persisted; `severity: reject` rules go to `rejection_reasons` as above.) Consumers that want advisory signals read `verification.warnings`; the routing decision (`parseRejectionReason`) only concerns `rejection_reasons`. A clean accepted submission carries no `warnings` key at all.
160
+
155
161
  ## Docs
156
162
 
157
163
  📖 Full handbook: **<https://dogfood-lab.github.io/testing-os/handbook/>**
package/cli.js ADDED
@@ -0,0 +1,447 @@
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
+ // Uppercase the rendered state word to match the sibling verdict banners
320
+ // (findings-render.js). This is display-only; the underlying enum
321
+ // (v.status, --json output) stays lowercase.
322
+ lines.push(`VERDICT: ${(v.status || (accepted ? 'accepted' : 'rejected')).toUpperCase()}`);
323
+ lines.push('');
324
+ lines.push(` schema_valid: ${v.schema_valid}`);
325
+ lines.push(` policy_valid: ${v.policy_valid}`);
326
+ lines.push(` provenance_confirmed: ${v.provenance_confirmed}`);
327
+ if (record.overall_verdict) {
328
+ const ov = record.overall_verdict;
329
+ const downgraded = ov.downgraded ? ` (downgraded from ${ov.proposed ?? 'null'})` : '';
330
+ lines.push(` verdict: ${ov.verified ?? 'null'}${downgraded}`);
331
+ }
332
+
333
+ if (accepted) {
334
+ lines.push('');
335
+ lines.push('No rejection reasons. This submission would be accepted.');
336
+ return lines.join('\n');
337
+ }
338
+
339
+ // Group reasons by class so the breakdown reads as "here is who must act,"
340
+ // not a flat undifferentiated list. parseRejectionReason owns the taxonomy.
341
+ const byClass = new Map();
342
+ for (const reason of reasons) {
343
+ const parsed = parseRejectionReason(reason);
344
+ if (!byClass.has(parsed.class)) byClass.set(parsed.class, []);
345
+ byClass.get(parsed.class).push(parsed);
346
+ }
347
+
348
+ lines.push('');
349
+ lines.push(`REJECTION REASONS (${reasons.length}):`);
350
+ // Stable, operator-meaningful order: payload problems first (most actionable
351
+ // by the consumer), then operational, ingest, unknown.
352
+ const ORDER = ['submission-bad', 'operational', 'ingest', 'unknown'];
353
+ for (const cls of ORDER) {
354
+ const items = byClass.get(cls);
355
+ if (!items || items.length === 0) continue;
356
+ lines.push('');
357
+ lines.push(` [${CLASS_LABEL[cls]}]`);
358
+ for (const parsed of items) {
359
+ const prefix = parsed.prefix ? `${parsed.prefix} ` : '';
360
+ lines.push(` - ${prefix}${parsed.detail}`);
361
+ }
362
+ }
363
+ return lines.join('\n');
364
+ }
365
+
366
+ /**
367
+ * Build the machine-readable (--json) result. Each reason is pre-classified so
368
+ * tooling never has to re-implement parseRejectionReason at the call site.
369
+ *
370
+ * @param {object} record
371
+ * @returns {object}
372
+ */
373
+ export function buildJsonResult(record) {
374
+ const v = record.verification || {};
375
+ const reasons = v.rejection_reasons || [];
376
+ return {
377
+ status: v.status ?? null,
378
+ run_id: record.run_id ?? null,
379
+ verdict: record.overall_verdict?.verified ?? null,
380
+ schema_valid: v.schema_valid ?? null,
381
+ policy_valid: v.policy_valid ?? null,
382
+ provenance_confirmed: v.provenance_confirmed ?? null,
383
+ rejection_reasons: reasons.map(reason => {
384
+ const parsed = parseRejectionReason(reason);
385
+ return { class: parsed.class, prefix: parsed.prefix, detail: parsed.detail, raw: reason };
386
+ })
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Run the CLI. Returns the process exit code instead of calling process.exit,
392
+ * so the function is unit-testable (a test drives it with argv + an injected
393
+ * stdout sink and asserts on the code + output).
394
+ *
395
+ * @param {string[]} argv - process.argv.slice(2)
396
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void, repoRoot?: string }} [io]
397
+ * @returns {Promise<number>} exit code (0 accepted / 1 rejected / 2 operator error)
398
+ */
399
+ export async function run(argv, io = {}) {
400
+ const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
401
+ const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
402
+ const repoRoot = io.repoRoot
403
+ ?? (process.env.VERIFY_REPO_ROOT ? resolve(process.env.VERIFY_REPO_ROOT) : resolve(__dirname, '../..'));
404
+
405
+ let opts;
406
+ try {
407
+ opts = parseArgs(argv);
408
+ } catch (e) {
409
+ err(`ERROR: ${e.message}`);
410
+ if (e.hint) err(` hint: ${e.hint}`);
411
+ return 2;
412
+ }
413
+
414
+ if (opts.help) {
415
+ out(USAGE);
416
+ return 0;
417
+ }
418
+
419
+ let record;
420
+ try {
421
+ const submission = loadSubmission(opts);
422
+ const { globalPolicy, repoPolicy, policyVersion } = loadPolicies(submission, repoRoot);
423
+ const provenance = resolveProvenance(opts.provenanceMode, submission);
424
+ record = await verify(submission, { globalPolicy, repoPolicy, provenance, policyVersion });
425
+ } catch (e) {
426
+ // OperatorError → exit 2 with a hint. Anything else thrown here is an
427
+ // unexpected fault in the preview path; surface it as exit 2 too (the
428
+ // consumer cannot fix a payload we never managed to verify).
429
+ err(`ERROR: ${e.message}`);
430
+ if (e.hint) err(` hint: ${e.hint}`);
431
+ return 2;
432
+ }
433
+
434
+ if (opts.mode === 'json') {
435
+ out(JSON.stringify(buildJsonResult(record)));
436
+ } else {
437
+ out(renderExplain(record));
438
+ }
439
+
440
+ return record.verification?.status === 'accepted' ? 0 : 1;
441
+ }
442
+
443
+ // --- CLI entrypoint ---
444
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'cli.js');
445
+ if (isMain) {
446
+ run(process.argv.slice(2)).then((code) => process.exit(code));
447
+ }
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,11 +200,24 @@ 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
- reasons.push(`provenance: verification failed: ${err.message}`);
212
+ // verify-A-002: the adapter THROWS on operational provider faults
213
+ // (429 rate-limit, 5xx outage, 401/403 token) and returns false only for
214
+ // a genuinely-absent run (404/transport). Emit a DISTINCT `provenance-fault:`
215
+ // prefix here so parseRejectionReason routes the incident to ops instead of
216
+ // bouncing an outage back to the submitter as submission-bad. The not-confirmed
217
+ // case below keeps the bare `provenance:` prefix (still submission-bad).
218
+ reasons.push(`provenance-fault: verification failed: ${err.message}`);
188
219
  }
189
- if (!provenanceConfirmed && !reasons.some(r => r.startsWith('provenance:'))) {
220
+ if (!provenanceConfirmed && !reasons.some(r => r.startsWith('provenance'))) {
190
221
  reasons.push('provenance: source run could not be confirmed');
191
222
  }
192
223
  }
@@ -218,6 +249,12 @@ export async function verify(submission, options) {
218
249
  // BEFORE handing the (broken) policy to `validatePolicy`, so the
219
250
  // operator sees a real "policy:" rejection in `rejection_reasons`.
220
251
  let policyValid = false;
252
+ // VERIFY-F4: severity:warn / severity:info policy rules surface here as an
253
+ // accepted-with-warning channel. Warnings NEVER enter `reasons` (which become
254
+ // rejection_reasons and flip status to 'rejected') — they land on
255
+ // verification.warnings at assembly so an operator sees the advisory without
256
+ // the submission being bounced.
257
+ const warnings = [];
221
258
  if (schemaResult.valid) {
222
259
  if (repoPolicy && repoPolicy.__torn === true) {
223
260
  const detail = repoPolicy.reason || 'repo policy YAML failed to parse';
@@ -228,6 +265,7 @@ export async function verify(submission, options) {
228
265
  if (policyRun.ok) {
229
266
  policyValid = policyRun.result.valid;
230
267
  reasons.push(...policyRun.result.errors.map(e => `policy: ${e}`));
268
+ warnings.push(...(policyRun.result.warnings || []).map(w => `policy: ${w}`));
231
269
  } else {
232
270
  reasons.push(policyRun.faultReason);
233
271
  }
@@ -275,7 +313,8 @@ export async function verify(submission, options) {
275
313
  provenance_confirmed: provenanceConfirmed,
276
314
  schema_valid: schemaResult.valid,
277
315
  policy_valid: policyValid,
278
- rejection_reasons: reasons
316
+ rejection_reasons: reasons,
317
+ ...(warnings.length ? { warnings } : {})
279
318
  },
280
319
  ...(submission.notes ? { notes: submission.notes } : {})
281
320
  };
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.4.0",
3
+ "version": "1.6.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"