@dogfood-lab/verify 1.3.2 → 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 +46 -25
- package/cli.js +444 -0
- package/index.js +68 -10
- package/package.json +8 -1
- package/parse-rejection.js +136 -0
- package/validators/policy.js +9 -1
- package/validators/provenance-gitlab.test.js +300 -0
- package/validators/provenance-registry.test.js +56 -0
- package/validators/provenance.js +232 -5
- package/validators/repo-binding.js +92 -0
- package/validators/repo-binding.test.js +118 -0
- package/validators/schema-version.js +111 -0
- package/validators/schema.js +8 -17
- package/validators/steps.js +10 -2
package/README.md
CHANGED
|
@@ -48,7 +48,8 @@ if (!result.ok) {
|
|
|
48
48
|
|
|
49
49
|
| Validator | Purpose |
|
|
50
50
|
|---|---|
|
|
51
|
-
| `validators/schema.js` | JSON Schema check against `@dogfood-lab/schemas` |
|
|
51
|
+
| `validators/schema.js` | JSON Schema check against `@dogfood-lab/schemas` (SHAPE gate) |
|
|
52
|
+
| `validators/schema-version.js` | `schema_version` VALUE gate — refuses an incompatible MAJOR against `SUPPORTED_SCHEMA_VERSIONS` |
|
|
52
53
|
| `validators/policy.js` | Per-repo policy compliance (prototype-pollution-safe deep merge) |
|
|
53
54
|
| `validators/provenance.js` | GitHub Actions run-ID confirmation via API (with timeout guard) |
|
|
54
55
|
| `validators/steps.js` | Step-by-step contract checks (gate accumulation, ordering) |
|
|
@@ -84,48 +85,68 @@ Provenance fields (`github_run_id`, `github_workflow_ref`) are required when `pr
|
|
|
84
85
|
|
|
85
86
|
The verifier emits two prefix classes:
|
|
86
87
|
|
|
87
|
-
**
|
|
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
|
+
|
|
90
|
+
**Submission-bad** — `class: 'submission-bad'` (the submitter's payload failed a validator gate; fix the submission and resubmit):
|
|
88
91
|
|
|
89
92
|
| Prefix | Source | Meaning |
|
|
90
93
|
|---|---|---|
|
|
91
94
|
| `schema:` | `validators/schema.js` | JSON Schema check on the submission/record envelope failed. The rest of the string carries the AJV path + message. |
|
|
92
|
-
| `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields,
|
|
95
|
+
| `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, etc.). |
|
|
93
96
|
| `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
|
|
94
97
|
| `provenance:` | `validators/provenance.js` | The GitHub run-id confirmation could not match the submitted commit/repo at the GitHub API. |
|
|
95
|
-
| `
|
|
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
|
+
| `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
|
+
| `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). |
|
|
101
|
+
| `CONTRACT_SCHEMA_TOO_OLD:` | `validators/schema-version.js` | The submission's `schema_version` declares a MAJOR **below** the supported floor. **The submitter must re-emit** against the current contract. A patch/minor delta inside the supported major range is NOT rejected. |
|
|
96
102
|
|
|
97
|
-
**
|
|
103
|
+
**Operational** — `class: 'operational'` (the validator itself threw an internal error; investigate the verifier, do NOT bounce to the submitter):
|
|
98
104
|
|
|
99
105
|
| Prefix | Source | Meaning |
|
|
100
106
|
|---|---|---|
|
|
101
107
|
| `VALIDATOR_FAULT_SCHEMA:` | `runValidator('schema', …)` catch | Internal exception inside the schema validator. The rest of the string carries the thrown `.message`. |
|
|
102
108
|
| `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
|
|
103
109
|
| `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
|
|
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
|
+
|
|
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).
|
|
114
|
+
|
|
115
|
+
**Ingest** — `class: 'ingest'` (an ingest-side load fault, not a verifier gate):
|
|
116
|
+
|
|
117
|
+
| Prefix | Source | Meaning |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `scenario-load:` | `packages/ingest/run.js` | A scenario referenced by `scenario_results` could not be loaded from the source repo (typed-reason: `timeout` / `not_found` / `parse_error` / `invalid_id`). |
|
|
104
120
|
|
|
105
121
|
### Operator hygiene
|
|
106
122
|
|
|
123
|
+
Discriminate by **class**, not by hand-rolled `.startsWith()` chains. `parseRejectionReason(reason)` returns `{ class, prefix, detail }`:
|
|
124
|
+
|
|
107
125
|
```js
|
|
108
|
-
|
|
126
|
+
import { parseRejectionReason } from '@dogfood-lab/verify';
|
|
127
|
+
|
|
109
128
|
for (const r of result.rejection_reasons) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
const { class: cls, prefix, detail } =
|
|
130
|
+
parseRejectionReason(r);
|
|
131
|
+
switch (cls) {
|
|
132
|
+
case 'operational':
|
|
133
|
+
// Verifier-side fault. Page ops; do NOT bounce
|
|
134
|
+
// back to the submitter as "fix your payload".
|
|
135
|
+
notifyOps(prefix, detail);
|
|
136
|
+
break;
|
|
137
|
+
case 'submission-bad':
|
|
138
|
+
// The payload failed a gate — surface to the
|
|
139
|
+
// submitter so they fix it and resubmit.
|
|
140
|
+
surfaceToSubmitter(prefix, detail);
|
|
141
|
+
break;
|
|
142
|
+
case 'ingest':
|
|
143
|
+
// Ingest-side scenario fetch. The typed reason in
|
|
144
|
+
// `detail` (timeout vs not_found/…) decides triage.
|
|
145
|
+
triageScenarioLoad(detail);
|
|
146
|
+
break;
|
|
147
|
+
default: // 'unknown'
|
|
148
|
+
// Unrecognized prefix — log + surface raw text.
|
|
149
|
+
log.warn('unknown rejection_reason', r);
|
|
129
150
|
}
|
|
130
151
|
}
|
|
131
152
|
```
|
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
|
+
}
|