@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 +9 -3
- package/cli.js +447 -0
- package/index.js +50 -11
- package/package.json +8 -1
- package/parse-rejection.js +146 -0
- package/validators/policy.js +83 -3
- package/validators/provenance-gitlab.test.js +375 -0
- package/validators/provenance-registry.test.js +56 -0
- package/validators/provenance.js +331 -31
- package/validators/repo-binding.js +92 -0
- package/validators/repo-binding.test.js +118 -0
- package/validators/schema.js +8 -17
- package/validators/steps.js +5 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
+
// 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.
|
|
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"
|