@dogfood-lab/verify 1.6.0 → 1.8.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 +25 -2
- package/cli-lint.js +209 -0
- package/cli.js +21 -1
- package/index.js +5 -0
- package/package.json +3 -1
- package/parse-rejection.js +10 -1
- package/validators/lint-policy.js +120 -0
- package/validators/policy.js +84 -12
- package/validators/predicate.js +449 -0
package/README.md
CHANGED
|
@@ -62,6 +62,28 @@ import { validateSchema } from '@dogfood-lab/verify/validators/schema.js';
|
|
|
62
62
|
import { validateProvenance } from '@dogfood-lab/verify/validators/provenance.js';
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
## CLI (`dogfood-verify`)
|
|
66
|
+
|
|
67
|
+
The package ships a `dogfood-verify` bin with two verbs.
|
|
68
|
+
|
|
69
|
+
**Verify** a submission (local dry-run / explain — never writes):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
dogfood-verify --file submission.json --explain # human verdict, reasons classified by who-fixes-it
|
|
73
|
+
dogfood-verify --file submission.json --json # machine-readable
|
|
74
|
+
# exit: 0 accepted · 1 rejected · 2 operator error
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Lint** a policy file (VERIFY-F3, author-time — no submission needed):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
dogfood-verify lint policies/repos/<org>/<repo>.yaml # or global-policy.yaml
|
|
81
|
+
dogfood-verify lint policies/global-policy.yaml --json # for CI
|
|
82
|
+
# exit: 0 clean or warnings-only · 1 errors · 2 operator error
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`lint` runs the structural schema gate **plus** the data-independent predicate checks (`unknown_field`, `max_depth`, `node_budget`) over every `when` predicate, and emits an **advisory** warning on the `[]` footgun (a negative operator over a `[]` path fails open) with the fail-closed `not(any(...))` rewrite as a suggestion — never auto-applied, never a hard error. It is the `opa check` analogue. It **cannot** statically catch a `type_mismatch` or a fan-out overrun (both are data-dependent) and says so. Full contract + coverage boundary: [`docs/policy-lint.md`](https://github.com/dogfood-lab/testing-os/blob/main/docs/policy-lint.md).
|
|
86
|
+
|
|
65
87
|
## Submission envelope
|
|
66
88
|
|
|
67
89
|
The full envelope shape is defined by `@dogfood-lab/schemas` (`dogfood-record-submission.schema.json`). Minimum required fields:
|
|
@@ -92,7 +114,8 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
|
|
|
92
114
|
| Prefix | Source | Meaning |
|
|
93
115
|
|---|---|---|
|
|
94
116
|
| `schema:` | `validators/schema.js` | JSON Schema check on the submission/record envelope failed. The rest of the string carries the AJV path + message. |
|
|
95
|
-
| `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements,
|
|
117
|
+
| `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, or a declarative `when`/`custom_rules` predicate matched — see the [policy DSL](https://dogfood-lab.github.io/testing-os/handbook/policy-dsl/)). |
|
|
118
|
+
| `policy-config:` | `validators/policy.js` | **VERIFY-F1.** A REPO custom-rule predicate hit an eval-time semantic fault the schema could not catch — an unknown leading field, a numeric operator against a non-number, or a depth/width/fan-out budget. The repo authored the bad rule, so the fix is the submitter's. (A malformed GLOBAL predicate is `VALIDATOR_FAULT_POLICY:` operational instead — see below.) |
|
|
96
119
|
| `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
|
|
97
120
|
| `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
121
|
| `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: …`. |
|
|
@@ -105,7 +128,7 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
|
|
|
105
128
|
| Prefix | Source | Meaning |
|
|
106
129
|
|---|---|---|
|
|
107
130
|
| `VALIDATOR_FAULT_SCHEMA:` | `runValidator('schema', …)` catch | Internal exception inside the schema validator. The rest of the string carries the thrown `.message`. |
|
|
108
|
-
| `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
|
|
131
|
+
| `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator — including a **GLOBAL** declarative-rule predicate fault (VERIFY-F1): a broken `policies/global-policy.yaml` is an ops incident (the studio's own config), so its predicate fault throws here rather than bouncing to the submitter. The repo-authored counterpart is `policy-config:` submission-bad. |
|
|
109
132
|
| `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
|
|
110
133
|
| `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
134
|
| `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. |
|
package/cli-lint.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-lint.js (VERIFY-F3) — the `dogfood-verify lint <policy-file>` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* A SEPARATE parse/render path from the verify CLI (cli.js): it takes a policy YAML
|
|
5
|
+
* (not a submission JSON) and reports static lint findings, so it does not share the
|
|
6
|
+
* verify arg parser or output. cli.js's `main` dispatcher routes the `lint` verb here
|
|
7
|
+
* and leaves the verify `run` path untouched. The exit contract mirrors that path:
|
|
8
|
+
*
|
|
9
|
+
* 0 — clean, or warnings-only (footgun advisories never block).
|
|
10
|
+
* 1 — one or more errors (schema-invalid, a static predicate fault, or unparseable YAML).
|
|
11
|
+
* 2 — operator error (file missing/unreadable, or a malformed invocation).
|
|
12
|
+
*
|
|
13
|
+
* YAML that fails to parse is exit 1 (a lint FINDING about the policy the author must fix —
|
|
14
|
+
* surfacing "line 4: bad indentation" is the lint's job), not exit 2. A file that does not
|
|
15
|
+
* exist is exit 2 (the author pointed at the wrong path). See docs/policy-lint.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import yaml from 'js-yaml';
|
|
21
|
+
|
|
22
|
+
import { lintPolicy, COVERAGE_NOTE } from './validators/lint-policy.js';
|
|
23
|
+
|
|
24
|
+
/** Operator-error sentinel → exit 2 (distinct from a lint finding, which is exit 1). */
|
|
25
|
+
class LintOperatorError extends Error {
|
|
26
|
+
constructor(message, hint) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'LintOperatorError';
|
|
29
|
+
this.hint = hint;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LINT_USAGE = `dogfood-verify lint — author-time static check for a policy file
|
|
34
|
+
|
|
35
|
+
USAGE:
|
|
36
|
+
dogfood-verify lint <policy-file> [--json]
|
|
37
|
+
|
|
38
|
+
WHAT IT CHECKS (no submission needed):
|
|
39
|
+
- structural validity against policy.schema.json
|
|
40
|
+
- every predicate's known leading field, combinator depth, and node budget
|
|
41
|
+
- an ADVISORY warning on the [] footgun (a negative op over a [] path fails open)
|
|
42
|
+
|
|
43
|
+
It CANNOT statically catch a type_mismatch or a fanout_budget overrun — those depend
|
|
44
|
+
on submission data. Run \`dogfood-verify --file <submission> --explain\` for that.
|
|
45
|
+
|
|
46
|
+
OPTIONS:
|
|
47
|
+
--json Machine-readable result for CI.
|
|
48
|
+
-h, --help Show this help.
|
|
49
|
+
|
|
50
|
+
EXIT CODES:
|
|
51
|
+
0 clean or warnings-only 1 errors found 2 operator error (bad flags / IO)`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse the lint argv (everything AFTER the `lint` verb). Accepts exactly one positional
|
|
55
|
+
* policy-file path plus optional `--json` / `--help`. Throws LintOperatorError (→ exit 2)
|
|
56
|
+
* on any malformed invocation.
|
|
57
|
+
*
|
|
58
|
+
* @param {string[]} argv
|
|
59
|
+
* @returns {{ help: boolean, file: string|null, json: boolean }}
|
|
60
|
+
*/
|
|
61
|
+
export function parseLintArgs(argv) {
|
|
62
|
+
let file = null;
|
|
63
|
+
let json = false;
|
|
64
|
+
let help = false;
|
|
65
|
+
|
|
66
|
+
for (const arg of argv) {
|
|
67
|
+
if (arg === '-h' || arg === '--help') { help = true; continue; }
|
|
68
|
+
if (arg === '--json') { json = true; continue; }
|
|
69
|
+
if (arg.startsWith('-')) {
|
|
70
|
+
throw new LintOperatorError(`unknown argument: ${arg}`, 'run `dogfood-verify lint --help` for usage');
|
|
71
|
+
}
|
|
72
|
+
if (file !== null) {
|
|
73
|
+
throw new LintOperatorError('more than one policy file given', 'lint one file at a time');
|
|
74
|
+
}
|
|
75
|
+
file = arg;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (help) return { help: true, file: null, json: false };
|
|
79
|
+
if (file === null) {
|
|
80
|
+
throw new LintOperatorError('no policy file provided', 'dogfood-verify lint <policy-file>');
|
|
81
|
+
}
|
|
82
|
+
return { help: false, file, json };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a policy file by its path so the report can name the origin (which decides the
|
|
87
|
+
* runtime fault class: global → operational, repo → submission-bad). Mirrors the
|
|
88
|
+
* `policies/global-policy.yaml` vs `policies/repos/<org>/<repo>.yaml` layout.
|
|
89
|
+
*/
|
|
90
|
+
export function originForPath(p) {
|
|
91
|
+
const norm = String(p).replace(/\\/g, '/');
|
|
92
|
+
if (/\/policies\/repos\//.test(norm)) return 'repo';
|
|
93
|
+
if (/(^|\/)global-policy\.yaml$/.test(norm)) return 'global';
|
|
94
|
+
return 'unknown';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Render the human (default) view of a lint result — verdict-first, ERROR before WARNING. */
|
|
98
|
+
export function renderLintText(result, file) {
|
|
99
|
+
const lines = [];
|
|
100
|
+
const verdict = !result.ok ? 'ERRORS' : (result.warnings.length ? 'CLEAN (advisory warnings)' : 'CLEAN');
|
|
101
|
+
lines.push(`VERDICT: ${verdict}`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(` file: ${file}`);
|
|
104
|
+
lines.push(` origin: ${result.origin}`);
|
|
105
|
+
|
|
106
|
+
if (result.errors.length) {
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`ERRORS (${result.errors.length}):`);
|
|
109
|
+
for (const e of result.errors) {
|
|
110
|
+
const field = e.field ? ` — field "${e.field}"` : '';
|
|
111
|
+
lines.push(` - [${e.label} ${e.code}] ${e.location}${field}`);
|
|
112
|
+
lines.push(` ${e.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.warnings.length) {
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push(`WARNINGS (${result.warnings.length}) — advisory; the author confirms intent, nothing is auto-applied:`);
|
|
119
|
+
for (const w of result.warnings) {
|
|
120
|
+
lines.push(` - [${w.label} ${w.code}] ${w.location} — field "${w.field}"`);
|
|
121
|
+
lines.push(` ${w.message}`);
|
|
122
|
+
lines.push(` ${w.suggestion}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.ok && !result.warnings.length) {
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('No findings. The policy passes static lint.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(`note: ${result.coverageNote}`);
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build the machine-readable (--json) result. */
|
|
137
|
+
export function buildLintJson(result, file) {
|
|
138
|
+
return {
|
|
139
|
+
file,
|
|
140
|
+
origin: result.origin,
|
|
141
|
+
ok: result.ok,
|
|
142
|
+
errors: result.errors,
|
|
143
|
+
warnings: result.warnings,
|
|
144
|
+
coverageNote: result.coverageNote,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run the lint subcommand. Returns the exit code (does not call process.exit) so it is
|
|
150
|
+
* unit-testable with an injected stdout/stderr sink.
|
|
151
|
+
*
|
|
152
|
+
* @param {string[]} argv - args AFTER the `lint` verb
|
|
153
|
+
* @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void }} [io]
|
|
154
|
+
* @returns {Promise<number>}
|
|
155
|
+
*/
|
|
156
|
+
export async function runLint(argv, io = {}) {
|
|
157
|
+
const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'));
|
|
158
|
+
const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'));
|
|
159
|
+
|
|
160
|
+
let opts;
|
|
161
|
+
try {
|
|
162
|
+
opts = parseLintArgs(argv);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
err(`ERROR: ${e.message}`);
|
|
165
|
+
if (e.hint) err(` hint: ${e.hint}`);
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (opts.help) {
|
|
170
|
+
out(LINT_USAGE);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const path = resolve(opts.file);
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = readFileSync(path, 'utf-8');
|
|
178
|
+
} catch (e) {
|
|
179
|
+
err(`ERROR: could not read policy file: ${path} — ${e.message}`);
|
|
180
|
+
err(' hint: check the path exists and is readable');
|
|
181
|
+
return 2;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let doc;
|
|
185
|
+
try {
|
|
186
|
+
doc = yaml.load(raw);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// A YAML parse failure is a lint finding about the policy (exit 1), not an operator error.
|
|
189
|
+
const where = e && e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
|
|
190
|
+
const result = {
|
|
191
|
+
ok: false,
|
|
192
|
+
origin: originForPath(path),
|
|
193
|
+
errors: [{
|
|
194
|
+
label: 'policy-schema:',
|
|
195
|
+
code: 'yaml_parse',
|
|
196
|
+
location: '/',
|
|
197
|
+
message: `policy YAML failed to parse${where} — ${e.message}`,
|
|
198
|
+
}],
|
|
199
|
+
warnings: [],
|
|
200
|
+
coverageNote: COVERAGE_NOTE,
|
|
201
|
+
};
|
|
202
|
+
out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const result = lintPolicy(doc, { origin: originForPath(path) });
|
|
207
|
+
out(opts.json ? JSON.stringify(buildLintJson(result, path)) : renderLintText(result, path));
|
|
208
|
+
return result.ok ? 0 : 1;
|
|
209
|
+
}
|
package/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ import yaml from 'js-yaml';
|
|
|
39
39
|
|
|
40
40
|
import { verify, parseRejectionReason } from './index.js';
|
|
41
41
|
import { stubProvenance, provenanceForProvider } from './validators/provenance.js';
|
|
42
|
+
import { runLint } from './cli-lint.js';
|
|
42
43
|
|
|
43
44
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
45
|
|
|
@@ -440,8 +441,27 @@ export async function run(argv, io = {}) {
|
|
|
440
441
|
return record.verification?.status === 'accepted' ? 0 : 1;
|
|
441
442
|
}
|
|
442
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Top-level dispatcher. The bin has two verbs:
|
|
446
|
+
* - `dogfood-verify lint <policy-file>` → the author-time policy lint (VERIFY-F3, cli-lint.js).
|
|
447
|
+
* - `dogfood-verify <flags>` → the verify dry-run/explain (the original `run` path).
|
|
448
|
+
*
|
|
449
|
+
* Dispatching on `argv[0] === 'lint'` is a purely additive change: that token previously hit the
|
|
450
|
+
* verify parser's default arm and threw `unknown argument: lint`. The verify path is unchanged.
|
|
451
|
+
*
|
|
452
|
+
* @param {string[]} argv - process.argv.slice(2)
|
|
453
|
+
* @param {object} [io] - injected stdout/stderr/repoRoot for testing
|
|
454
|
+
* @returns {Promise<number>} exit code
|
|
455
|
+
*/
|
|
456
|
+
export async function main(argv, io = {}) {
|
|
457
|
+
if (argv[0] === 'lint') {
|
|
458
|
+
return runLint(argv.slice(1), io);
|
|
459
|
+
}
|
|
460
|
+
return run(argv, io);
|
|
461
|
+
}
|
|
462
|
+
|
|
443
463
|
// --- CLI entrypoint ---
|
|
444
464
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'cli.js');
|
|
445
465
|
if (isMain) {
|
|
446
|
-
|
|
466
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
447
467
|
}
|
package/index.js
CHANGED
|
@@ -265,6 +265,11 @@ export async function verify(submission, options) {
|
|
|
265
265
|
if (policyRun.ok) {
|
|
266
266
|
policyValid = policyRun.result.valid;
|
|
267
267
|
reasons.push(...policyRun.result.errors.map(e => `policy: ${e}`));
|
|
268
|
+
// VERIFY-F1: a malformed REPO custom-rule predicate is a distinct
|
|
269
|
+
// `policy-config:` rejection (submission-bad — the repo authored the bad
|
|
270
|
+
// rule). A malformed GLOBAL predicate never reaches here; it throws and
|
|
271
|
+
// surfaces as VALIDATOR_FAULT_POLICY (operational) via the else branch.
|
|
272
|
+
reasons.push(...(policyRun.result.configErrors || []).map(e => `policy-config: ${e}`));
|
|
268
273
|
warnings.push(...(policyRun.result.warnings || []).map(w => `policy: ${w}`));
|
|
269
274
|
} else {
|
|
270
275
|
reasons.push(policyRun.faultReason);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/verify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./index.js",
|
|
12
12
|
"./cli.js": "./cli.js",
|
|
13
|
+
"./cli-lint.js": "./cli-lint.js",
|
|
13
14
|
"./parse-rejection.js": "./parse-rejection.js",
|
|
14
15
|
"./validators/*": "./validators/*",
|
|
15
16
|
"./validators/*.js": "./validators/*.js"
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"files": [
|
|
22
23
|
"index.js",
|
|
23
24
|
"cli.js",
|
|
25
|
+
"cli-lint.js",
|
|
24
26
|
"parse-rejection.js",
|
|
25
27
|
"validators/",
|
|
26
28
|
"README.md",
|
package/parse-rejection.js
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
*
|
|
24
24
|
* The prefix vocabulary below is enumerated from the ACTUAL emitters — it is
|
|
25
25
|
* NOT invented:
|
|
26
|
-
* - verify/index.js: schema:, policy:,
|
|
26
|
+
* - verify/index.js: schema:, policy:, policy-config: (VERIFY-F1,
|
|
27
|
+
* repo custom-rule predicate fault → submission-bad),
|
|
28
|
+
* steps[<id>]:,
|
|
27
29
|
* provenance: (run absent → submission-bad),
|
|
28
30
|
* provenance-fault: (provider 429/5xx/401/403
|
|
29
31
|
* → operational), repo:,
|
|
@@ -62,6 +64,13 @@
|
|
|
62
64
|
const LITERAL_PREFIXES = [
|
|
63
65
|
// submission-bad
|
|
64
66
|
{ match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
|
|
67
|
+
// VERIFY-F1: a malformed REPO custom-rule predicate (eval-time semantic fault the
|
|
68
|
+
// schema could not catch). submission-bad — the repo authored the bad policy YAML,
|
|
69
|
+
// so the fix belongs to the submitter, not studio ops. Ordered BEFORE `policy:`
|
|
70
|
+
// to keep the most-specific-first invariant (the tokens don't overlap — `policy:`
|
|
71
|
+
// ends at the colon, `policy-config:` continues with `-config:` — but the order is
|
|
72
|
+
// explicit). A malformed GLOBAL predicate is operational (VALIDATOR_FAULT_POLICY).
|
|
73
|
+
{ match: 'policy-config:', prefix: 'policy-config:', class: 'submission-bad' },
|
|
65
74
|
{ match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
|
|
66
75
|
// operational — a provider-side provenance FAULT (429/5xx/401/403). The
|
|
67
76
|
// adapter THROWS these (verify-A-002); index.js catches the throw and emits
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy lint (VERIFY-F3)
|
|
3
|
+
*
|
|
4
|
+
* `lintPolicy(policyDoc, { origin })` is the author-time check behind the
|
|
5
|
+
* `dogfood-verify lint` verb: it validates a whole policy file WITHOUT a submission,
|
|
6
|
+
* batch-reporting every fault. It is the `opa check` analogue named (as a deferred
|
|
7
|
+
* companion) in docs/policy-dsl.md and specified in docs/policy-lint.md.
|
|
8
|
+
*
|
|
9
|
+
* Three passes, mirroring the runtime's own layering:
|
|
10
|
+
* 1. Structural gate — `validatePayload('policy', …)` against policy.schema.json (the same
|
|
11
|
+
* gate loadGlobalPolicy / loadRepoPolicy run). Catches unknown op, malformed/banned field,
|
|
12
|
+
* mixed node, value-arity, custom_rules-under-defaults, additionalProperties.
|
|
13
|
+
* 2. Static predicate walk — the data-independent semantic faults the schema cannot express:
|
|
14
|
+
* unknown leading field, combinator over-depth, node budget (via predicate.js#lintPredicate).
|
|
15
|
+
* 3. `[]`-footgun advisory — a deterministic warning (never an error) on a negative op over a
|
|
16
|
+
* `[]` path (via predicate.js#findEmptyArrayFootguns).
|
|
17
|
+
*
|
|
18
|
+
* Coverage boundary (the VERIFY-F2 over-claim lesson): `type_mismatch` and `fanout_budget` are
|
|
19
|
+
* DATA-DEPENDENT and cannot be caught statically. `coverageNote` says so — a clean lint is "no
|
|
20
|
+
* static fault and no footgun," NOT "this policy can never produce a policy-config: rejection."
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
24
|
+
import { lintPredicate, findEmptyArrayFootguns } from './predicate.js';
|
|
25
|
+
|
|
26
|
+
/** Stated in every lint result so a clean verdict is never read as full coverage. */
|
|
27
|
+
export const COVERAGE_NOTE =
|
|
28
|
+
'Static lint only — it cannot catch a `type_mismatch` (a numeric op over a non-number field) ' +
|
|
29
|
+
'or a `fanout_budget` overrun; both depend on submission data. Run ' +
|
|
30
|
+
'`dogfood-verify --file <submission> --explain` to exercise the data-dependent path.';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lint a parsed policy document.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} policyDoc - The parsed policy YAML/JSON (any value; a non-object is reported
|
|
36
|
+
* by the schema gate).
|
|
37
|
+
* @param {{ origin?: 'global'|'repo'|'unknown' }} [opts] - The policy's origin, used only for
|
|
38
|
+
* reporting (the caller derives it from the file path).
|
|
39
|
+
* @returns {{
|
|
40
|
+
* ok: boolean, origin: string, coverageNote: string,
|
|
41
|
+
* errors: { label: string, code: string, location: string, field?: string, message: string }[],
|
|
42
|
+
* warnings: { label: string, code: string, location: string, field: string, suggestion: string, message: string }[]
|
|
43
|
+
* }} `ok` is true iff there are no errors; warnings (footguns) never affect `ok`.
|
|
44
|
+
*/
|
|
45
|
+
export function lintPolicy(policyDoc, { origin = 'unknown' } = {}) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const warnings = [];
|
|
48
|
+
|
|
49
|
+
// 1. Structural schema gate.
|
|
50
|
+
const schema = validatePayload('policy', policyDoc);
|
|
51
|
+
for (const e of schema.errors) {
|
|
52
|
+
errors.push({
|
|
53
|
+
label: 'policy-schema:',
|
|
54
|
+
code: e.keyword || 'schema',
|
|
55
|
+
location: e.path || '/',
|
|
56
|
+
message: e.message || 'schema violation',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2 + 3. Static predicate walk + footgun advisory over every `when` location. Defensive: a
|
|
61
|
+
// schema-invalid doc may have malformed predicates; the walkers tolerate non-objects.
|
|
62
|
+
for (const { when, scope, location } of collectPredicates(policyDoc)) {
|
|
63
|
+
for (const f of lintPredicate(when, scope)) {
|
|
64
|
+
errors.push({ label: 'policy-config:', code: f.code, location, field: f.field, message: f.message });
|
|
65
|
+
}
|
|
66
|
+
for (const g of findEmptyArrayFootguns(when)) {
|
|
67
|
+
warnings.push({
|
|
68
|
+
label: 'policy-footgun:',
|
|
69
|
+
code: 'footgun-empty-array',
|
|
70
|
+
location,
|
|
71
|
+
field: g.field,
|
|
72
|
+
suggestion: g.suggestion,
|
|
73
|
+
message:
|
|
74
|
+
`negative operator "${g.op}" over the array path "${g.field}" fails OPEN on an empty or ` +
|
|
75
|
+
`absent array — the rule silently does not fire when there are no elements`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ok: errors.length === 0, origin, errors, warnings, coverageNote: COVERAGE_NOTE };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Enumerate every `when` predicate in a policy with its evaluation scope and an operator-readable
|
|
85
|
+
* location. Walks the SAME two locations the runtime evaluates (validators/policy.js):
|
|
86
|
+
* - `global_rules[].when` at scope `rule.scope || 'submission'`
|
|
87
|
+
* - `surfaces.<surface>.custom_rules[].when` at scope `scenario_result`
|
|
88
|
+
* `custom_rules` under `defaults` is schema-forbidden, so it is not walked here — the structural
|
|
89
|
+
* gate already reports it if present (matching the runtime, which never evaluates it).
|
|
90
|
+
*/
|
|
91
|
+
function* collectPredicates(policyDoc) {
|
|
92
|
+
if (policyDoc === null || typeof policyDoc !== 'object') return;
|
|
93
|
+
|
|
94
|
+
const globalRules = Array.isArray(policyDoc.global_rules) ? policyDoc.global_rules : [];
|
|
95
|
+
for (let i = 0; i < globalRules.length; i++) {
|
|
96
|
+
const rule = globalRules[i];
|
|
97
|
+
if (rule && typeof rule === 'object' && rule.when != null) {
|
|
98
|
+
yield {
|
|
99
|
+
when: rule.when,
|
|
100
|
+
scope: rule.scope || 'submission',
|
|
101
|
+
location: `global_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const surfaces = policyDoc.surfaces && typeof policyDoc.surfaces === 'object' ? policyDoc.surfaces : {};
|
|
107
|
+
for (const [surface, sp] of Object.entries(surfaces)) {
|
|
108
|
+
const customRules = sp && Array.isArray(sp.custom_rules) ? sp.custom_rules : [];
|
|
109
|
+
for (let i = 0; i < customRules.length; i++) {
|
|
110
|
+
const rule = customRules[i];
|
|
111
|
+
if (rule && typeof rule === 'object' && rule.when != null) {
|
|
112
|
+
yield {
|
|
113
|
+
when: rule.when,
|
|
114
|
+
scope: 'scenario_result',
|
|
115
|
+
location: `surfaces.${surface}.custom_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/validators/policy.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Global rules are non-overridable. Repo policies add surface-specific requirements.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { evaluatePredicate, buildReason, PredicateError } from './predicate.js';
|
|
9
|
+
|
|
8
10
|
function deepMerge(target, source) {
|
|
9
11
|
const result = { ...target };
|
|
10
12
|
for (const key of Object.keys(source)) {
|
|
@@ -44,7 +46,6 @@ function deepMerge(target, source) {
|
|
|
44
46
|
const KNOWN_REJECT_RULE_IDS = new Set([
|
|
45
47
|
// handled by the switch in validatePolicy
|
|
46
48
|
'scenario-minimum',
|
|
47
|
-
'attested-if-human',
|
|
48
49
|
'blocked-needs-reason',
|
|
49
50
|
// enforced by other validators or by verify() itself (default-arm no-op is correct)
|
|
50
51
|
'schema-valid',
|
|
@@ -54,6 +55,34 @@ const KNOWN_REJECT_RULE_IDS = new Set([
|
|
|
54
55
|
'no-verdict-upgrade',
|
|
55
56
|
]);
|
|
56
57
|
|
|
58
|
+
// VERIFY-F1: `attested-if-human` is intentionally NOT in the set above. It used to
|
|
59
|
+
// be a switch arm; it is now enforced DECLARATIVELY (it carries a `when` predicate
|
|
60
|
+
// in policies/global-policy.yaml and flows through the engine). If it ever appears
|
|
61
|
+
// WITHOUT a `when` — a misconfigured migration — it falls to the default arm and is
|
|
62
|
+
// NOT in KNOWN_REJECT_RULE_IDS, so it surfaces the "no enforcement" diagnostic
|
|
63
|
+
// (fail-closed) instead of silently doing nothing.
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* VERIFY-F1: evaluate a declarative rule's `when` predicate against each root and
|
|
67
|
+
* return one reason string (`[id] body`) per match. Throws {@link PredicateError}
|
|
68
|
+
* on a semantic fault; the caller decides whether that is operational (global rule)
|
|
69
|
+
* or submission-bad (repo custom rule).
|
|
70
|
+
*/
|
|
71
|
+
function collectMatches(rule, roots, scope) {
|
|
72
|
+
const reasons = [];
|
|
73
|
+
for (const root of roots) {
|
|
74
|
+
if (evaluatePredicate(rule.when, root, scope)) reasons.push(buildReason(rule, root));
|
|
75
|
+
}
|
|
76
|
+
return reasons;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Route matched reasons by severity. reject -> errors, warn -> warnings, info -> logged-only. */
|
|
80
|
+
function routeReasons(rule, reasons, errors, warnings) {
|
|
81
|
+
if (rule.severity === 'reject') errors.push(...reasons);
|
|
82
|
+
else if (rule.severity === 'warn') warnings.push(...reasons);
|
|
83
|
+
// info: intentionally non-surfacing
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
/**
|
|
58
87
|
* Resolve the effective surface policy for a given product surface.
|
|
59
88
|
* Repo policy overrides global defaults per surface.
|
|
@@ -80,7 +109,10 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
|
|
|
80
109
|
* @param {object} options
|
|
81
110
|
* @param {object} options.globalPolicy
|
|
82
111
|
* @param {object|null} options.repoPolicy
|
|
83
|
-
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
112
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[], configErrors: string[] }}
|
|
113
|
+
* `configErrors` (VERIFY-F1) are eval-time repo custom-rule predicate faults; the
|
|
114
|
+
* caller emits them with a `policy-config:` prefix (submission-bad). A non-empty
|
|
115
|
+
* `configErrors` makes `valid` false.
|
|
84
116
|
*/
|
|
85
117
|
export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
86
118
|
const errors = [];
|
|
@@ -88,12 +120,36 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
88
120
|
// instead of being silently dropped. A populated warnings[] never affects
|
|
89
121
|
// `valid` — the caller (index.js) routes it to verification.warnings.
|
|
90
122
|
const warnings = [];
|
|
123
|
+
// VERIFY-F1: a malformed REPO custom-rule predicate (an eval-time semantic fault
|
|
124
|
+
// the schema could not catch — unknown leading field, numeric op vs non-number,
|
|
125
|
+
// over-depth) is a `policy-config:` submission-bad reason recorded here (the repo
|
|
126
|
+
// authored the bad rule; do NOT page studio ops). A non-empty configErrors flips
|
|
127
|
+
// `valid` false (fail-closed). The caller prefixes these `policy-config: `, not
|
|
128
|
+
// `policy: `. Global-rule predicate faults are NOT collected here — they throw
|
|
129
|
+
// (-> VALIDATOR_FAULT_POLICY, operational).
|
|
130
|
+
const configErrors = [];
|
|
91
131
|
|
|
92
132
|
// --- Global rules (non-overridable) ---
|
|
93
133
|
|
|
94
134
|
const globalRules = globalPolicy.global_rules || [];
|
|
95
135
|
|
|
96
136
|
for (const rule of globalRules) {
|
|
137
|
+
// VERIFY-F1: a rule carrying a `when` predicate is enforced DECLARATIVELY by
|
|
138
|
+
// the engine; a rule WITHOUT `when` falls to the legacy severity handling +
|
|
139
|
+
// id switch below (the seven built-ins that stay code-enforced). A GLOBAL-rule
|
|
140
|
+
// predicate fault is allowed to THROW (-> VALIDATOR_FAULT_POLICY, operational):
|
|
141
|
+
// a broken global policy is an operator/ops incident, mirroring loadGlobalPolicy's
|
|
142
|
+
// fail-loud schema gate. scope 'scenario_result' evaluates per element in array
|
|
143
|
+
// order (one reason per offending element); 'submission' (default) evaluates once.
|
|
144
|
+
if (rule.when) {
|
|
145
|
+
const scope = rule.scope || 'submission';
|
|
146
|
+
const roots = scope === 'scenario_result'
|
|
147
|
+
? (submission.scenario_results || [])
|
|
148
|
+
: [submission];
|
|
149
|
+
routeReasons(rule, collectMatches(rule, roots, scope), errors, warnings);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
97
153
|
// VERIFY-F4: a global warn-rule is accepted-with-warning; an info-rule logs
|
|
98
154
|
// only. Neither may reject. Mirrors the PROACT-VERIFY-002 discipline of not
|
|
99
155
|
// dropping operator-authored rules on the floor: a declared rule that the
|
|
@@ -115,15 +171,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
115
171
|
}
|
|
116
172
|
break;
|
|
117
173
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`[${rule.id}] scenario "${sr.scenario_id}": execution_mode is "${sr.execution_mode}" but attested_by is missing`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
break;
|
|
174
|
+
// VERIFY-F1: the `attested-if-human` switch arm was removed in v1.7.0. The rule
|
|
175
|
+
// is now enforced declaratively via a `when` predicate (see the `if (rule.when)`
|
|
176
|
+
// branch above and policies/global-policy.yaml). The differential-equivalence
|
|
177
|
+
// gate proves the declarative form is byte-identical to this removed arm.
|
|
127
178
|
|
|
128
179
|
case 'blocked-needs-reason':
|
|
129
180
|
for (const sr of submission.scenario_results || []) {
|
|
@@ -215,6 +266,22 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
215
266
|
}
|
|
216
267
|
}
|
|
217
268
|
}
|
|
269
|
+
|
|
270
|
+
// VERIFY-F1: declarative custom rules for this surface, evaluated per
|
|
271
|
+
// scenario_result. Additive-only (reject/warn/info, no accept verb) so a repo
|
|
272
|
+
// can never weaken a global gate. A predicate fault here is a `policy-config:`
|
|
273
|
+
// submission-bad reason (the repo authored the bad rule), NOT an ops page.
|
|
274
|
+
for (const customRule of surfacePolicy.custom_rules || []) {
|
|
275
|
+
try {
|
|
276
|
+
routeReasons(customRule, collectMatches(customRule, [sr], 'scenario_result'), errors, warnings);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof PredicateError) {
|
|
279
|
+
configErrors.push(`rule "${customRule.id}": ${err.message}`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
218
285
|
}
|
|
219
286
|
|
|
220
287
|
const uniqueSurfaces = [...new Set((submission.scenario_results || []).map(sr => sr.product_surface))];
|
|
@@ -256,5 +323,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
256
323
|
}
|
|
257
324
|
}
|
|
258
325
|
|
|
259
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
valid: errors.length === 0 && configErrors.length === 0,
|
|
328
|
+
errors,
|
|
329
|
+
warnings,
|
|
330
|
+
configErrors,
|
|
331
|
+
};
|
|
260
332
|
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate engine (VERIFY-F1)
|
|
3
|
+
*
|
|
4
|
+
* A bounded, NO-EVAL interpreter for the declarative policy DSL. Operators author
|
|
5
|
+
* rules as structured predicates in their YAML policy file (field-selector +
|
|
6
|
+
* operator + value, composed with all/any/not/implies); this module evaluates
|
|
7
|
+
* them. There is no `eval`, `Function`, `vm`, dynamic import, or template-string
|
|
8
|
+
* execution anywhere in the path — the evaluator is a pure tree-walk. That is the
|
|
9
|
+
* whole safety thesis (see docs/policy-dsl.md).
|
|
10
|
+
*
|
|
11
|
+
* The schema (`policy.schema.json`, recursive `predicate` $def) is the FIRST gate:
|
|
12
|
+
* unknown operators, malformed nodes, banned path segments, and arity errors are
|
|
13
|
+
* rejected at policy-LOAD time by `validatePayload('policy', …)` and inherit the
|
|
14
|
+
* existing origin-based classification (global policy → throws/operational; repo
|
|
15
|
+
* policy → `__torn`/submission-bad). This module is the SECOND gate: the residual
|
|
16
|
+
* SEMANTIC faults the schema cannot express — an unknown leading field, a numeric
|
|
17
|
+
* operator against a non-number, or a predicate nested past the depth cap — surface
|
|
18
|
+
* as a {@link PredicateError} that the caller (policy.js) turns into a
|
|
19
|
+
* `policy-config:` reason, classified by predicate origin. A predicate is never
|
|
20
|
+
* silently skipped and never throws uncaught (fail-closed; Saltzer & Schroeder 1975).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/** Combinator nesting cap. A predicate nested deeper is a `max_depth` fault. */
|
|
27
|
+
export const PREDICATE_MAX_DEPTH = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Total-work budgets (VERIFY-F1 Phase 3 hardening — the depth cap bounds NESTING
|
|
31
|
+
* but not WIDTH or array fan-out). The engine runs synchronously inside the ingest
|
|
32
|
+
* pipeline, so an unbounded tree-walk would block the event loop. Both budgets are
|
|
33
|
+
* generous relative to any real policy (a hand-authored rule is a handful of nodes
|
|
34
|
+
* over a handful of elements) and exist only to refuse a pathological / hostile
|
|
35
|
+
* predicate as a classified `policy-config:` fault rather than letting it spin.
|
|
36
|
+
*/
|
|
37
|
+
export const PREDICATE_MAX_NODES = 10_000; // visited predicate nodes per evaluation
|
|
38
|
+
export const PREDICATE_MAX_FRONTIER = 500_000; // selected values from a `[]` fan-out
|
|
39
|
+
|
|
40
|
+
/** Field-path segments that must never be traversed (prototype-pollution guard). */
|
|
41
|
+
const POISON_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
42
|
+
|
|
43
|
+
/** The closed operator set. Mirrors the `op` enum in policy.schema.json. */
|
|
44
|
+
const OPERATORS = new Set([
|
|
45
|
+
'equals', 'not_equals', 'in', 'not_in', 'contains', 'not_contains',
|
|
46
|
+
'exists', 'not_exists', 'gt', 'gte', 'lt', 'lte',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A structured predicate fault (compile- or eval-time). The `code` is a stable
|
|
51
|
+
* machine token; the message is operator-actionable (names the rule + the exact
|
|
52
|
+
* problem). The caller maps this to a `policy-config:` rejection reason, classified
|
|
53
|
+
* by the predicate's origin (global → operational, repo → submission-bad).
|
|
54
|
+
*/
|
|
55
|
+
export class PredicateError extends Error {
|
|
56
|
+
constructor(code, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'PredicateError';
|
|
59
|
+
this.code = code;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Known leading-field sets per scope, derived from the submission schema so the
|
|
65
|
+
* allowlist never drifts from the contract (CLAUDE.md rule #5: schemas via
|
|
66
|
+
* createRequire). Only the LEADING path segment is validated — a deeper typo just
|
|
67
|
+
* resolves to `undefined` (no match), but a wrong leading field is an operator
|
|
68
|
+
* error worth a diagnostic.
|
|
69
|
+
*/
|
|
70
|
+
const KNOWN_FIELDS = (() => {
|
|
71
|
+
const require = createRequire(import.meta.url);
|
|
72
|
+
const schemaPath = require.resolve('@dogfood-lab/schemas/json/dogfood-record-submission.schema.json');
|
|
73
|
+
const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
74
|
+
const submission = new Set(Object.keys(schema.properties || {}));
|
|
75
|
+
const srProps = schema.properties?.scenario_results?.items?.properties || {};
|
|
76
|
+
const scenario_result = new Set(Object.keys(srProps));
|
|
77
|
+
return { submission, scenario_result };
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
/** Split a dotted field path into segments, normalizing the `[]` array marker. */
|
|
81
|
+
function splitPath(field) {
|
|
82
|
+
// 'scenario_results[].tags' -> ['scenario_results', '[]', 'tags']
|
|
83
|
+
const segments = [];
|
|
84
|
+
for (const raw of String(field).split('.')) {
|
|
85
|
+
const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[\])?$/);
|
|
86
|
+
if (!m) throw new PredicateError('bad_field', `field path segment "${raw}" is malformed`);
|
|
87
|
+
segments.push(m[1]);
|
|
88
|
+
if (m[2]) segments.push('[]');
|
|
89
|
+
}
|
|
90
|
+
return segments;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read one own-property segment without ever touching the prototype chain. */
|
|
94
|
+
function safeGet(value, segment) {
|
|
95
|
+
if (POISON_SEGMENTS.has(segment)) {
|
|
96
|
+
// Defense in depth — the schema `pattern` already bans these, but the
|
|
97
|
+
// evaluator never trusts the schema alone for a security-critical read.
|
|
98
|
+
throw new PredicateError('banned_segment', `field path segment "${segment}" is forbidden`);
|
|
99
|
+
}
|
|
100
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
101
|
+
return Object.hasOwn(value, segment) ? value[segment] : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a field path to the list of values it selects. A path with no `[]`
|
|
106
|
+
* yields exactly one value (possibly `undefined`, preserving "field absent" so
|
|
107
|
+
* `not_exists` works). Each `[]` segment expands the current frontier across the
|
|
108
|
+
* elements of the arrays at that position. The only iteration in the whole DSL.
|
|
109
|
+
*/
|
|
110
|
+
function resolvePath(root, segments) {
|
|
111
|
+
let frontier = [root];
|
|
112
|
+
for (const seg of segments) {
|
|
113
|
+
if (seg === '[]') {
|
|
114
|
+
const next = [];
|
|
115
|
+
for (const v of frontier) {
|
|
116
|
+
if (!Array.isArray(v)) continue;
|
|
117
|
+
// Explicit element loop, NOT `next.push(...v)` — the spread form blows the
|
|
118
|
+
// call stack on a large array (RangeError) before any budget check fires.
|
|
119
|
+
for (const el of v) {
|
|
120
|
+
if (next.length >= PREDICATE_MAX_FRONTIER) {
|
|
121
|
+
throw new PredicateError(
|
|
122
|
+
'fanout_budget',
|
|
123
|
+
`field path selection exceeds the limit of ${PREDICATE_MAX_FRONTIER} elements`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
next.push(el);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
frontier = next;
|
|
130
|
+
} else {
|
|
131
|
+
frontier = frontier.map(v => safeGet(v, seg));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return frontier;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether a field's LEADING segment is unknown for a scope, returning the
|
|
139
|
+
* fault rather than throwing it. Factored out so the eval path ({@link checkLeadingField},
|
|
140
|
+
* fail-on-first) and the lint path ({@link lintPredicate}, batch-collect) share one
|
|
141
|
+
* source of truth — a divergence between author-time and runtime diagnostics is
|
|
142
|
+
* impossible by construction. Data-INDEPENDENT (the known-field set is derived from
|
|
143
|
+
* the schema), so it is exactly the kind of check the lint can run without a submission.
|
|
144
|
+
*/
|
|
145
|
+
function leadingFieldFault(field, scope) {
|
|
146
|
+
const leading = String(field).split('.')[0].replace('[]', '');
|
|
147
|
+
const known = KNOWN_FIELDS[scope];
|
|
148
|
+
if (known && !known.has(leading)) {
|
|
149
|
+
return new PredicateError(
|
|
150
|
+
'unknown_field',
|
|
151
|
+
`field "${field}" references unknown leading field "${leading}" (known ${scope} fields: ${[...known].join(', ')})`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Validate the leading field segment against the scope's known-field set (eval path: throws). */
|
|
158
|
+
function checkLeadingField(field, scope) {
|
|
159
|
+
const fault = leadingFieldFault(field, scope);
|
|
160
|
+
if (fault) throw fault;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Apply one operator to a single resolved value + comparand. */
|
|
164
|
+
function applyOp(op, value, comparand) {
|
|
165
|
+
switch (op) {
|
|
166
|
+
case 'equals': return value === comparand;
|
|
167
|
+
case 'not_equals': return value !== comparand;
|
|
168
|
+
case 'in': return Array.isArray(comparand) && comparand.includes(value);
|
|
169
|
+
case 'not_in': return Array.isArray(comparand) && !comparand.includes(value);
|
|
170
|
+
case 'contains':
|
|
171
|
+
if (Array.isArray(value)) return value.includes(comparand);
|
|
172
|
+
if (typeof value === 'string') return value.includes(comparand);
|
|
173
|
+
return false;
|
|
174
|
+
case 'not_contains':
|
|
175
|
+
// not_contains is the negation of contains, so a missing/non-collection
|
|
176
|
+
// field genuinely "does not contain" the value (matches the v1.6.0
|
|
177
|
+
// required_tags semantics — a tagless scenario fails required_tags).
|
|
178
|
+
return !applyOp('contains', value, comparand);
|
|
179
|
+
case 'exists': return Boolean(value);
|
|
180
|
+
case 'not_exists': return !value;
|
|
181
|
+
case 'gt': case 'gte': case 'lt': case 'lte': {
|
|
182
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
183
|
+
throw new PredicateError(
|
|
184
|
+
'type_mismatch',
|
|
185
|
+
`operator "${op}" requires a numeric field value but got ${describe(value)}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (op === 'gt') return value > comparand;
|
|
189
|
+
if (op === 'gte') return value >= comparand;
|
|
190
|
+
if (op === 'lt') return value < comparand;
|
|
191
|
+
return value <= comparand;
|
|
192
|
+
}
|
|
193
|
+
default:
|
|
194
|
+
// Unreachable when the schema gate ran (op is enum-constrained), but the
|
|
195
|
+
// evaluator fails closed rather than trusting the schema.
|
|
196
|
+
throw new PredicateError('unknown_op', `unknown operator "${op}"`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function describe(value) {
|
|
201
|
+
if (value === undefined) return 'undefined (field absent)';
|
|
202
|
+
if (value === null) return 'null';
|
|
203
|
+
if (Array.isArray(value)) return 'an array';
|
|
204
|
+
return `${typeof value} (${JSON.stringify(value)})`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isCombinator(node) {
|
|
208
|
+
return node != null && typeof node === 'object' &&
|
|
209
|
+
(Array.isArray(node.all) || Array.isArray(node.any) || node.not != null || Array.isArray(node.implies));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Evaluate a leaf `{field, op, value?}` against a root, with "any element" semantics. */
|
|
213
|
+
function evalLeaf(node, root, scope) {
|
|
214
|
+
if (!OPERATORS.has(node.op)) {
|
|
215
|
+
throw new PredicateError('unknown_op', `unknown operator "${node.op}"`);
|
|
216
|
+
}
|
|
217
|
+
checkLeadingField(node.field, scope);
|
|
218
|
+
const values = resolvePath(root, splitPath(node.field));
|
|
219
|
+
// The predicate is true when ANY selected value satisfies the operator. For a
|
|
220
|
+
// scalar (no `[]`) path this is just that single value; for an empty `[]`
|
|
221
|
+
// selection there is nothing to match, so the leaf is false.
|
|
222
|
+
for (const v of values) {
|
|
223
|
+
if (applyOp(node.op, v, node.value)) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Evaluate a predicate node against a root object (the whole submission for
|
|
230
|
+
* `submission` scope, or a single scenario_result for `scenario_result` scope).
|
|
231
|
+
*
|
|
232
|
+
* @param {object} node - The predicate tree.
|
|
233
|
+
* @param {object} root - The data to evaluate against.
|
|
234
|
+
* @param {'submission'|'scenario_result'} scope - Field-resolution scope.
|
|
235
|
+
* @returns {boolean} True when the (violation) predicate matches.
|
|
236
|
+
* @throws {PredicateError} On a semantic fault (unknown field, type mismatch, depth, budget).
|
|
237
|
+
*/
|
|
238
|
+
export function evaluatePredicate(node, root, scope) {
|
|
239
|
+
// The budget is per-evaluation (one call over one root). It bounds the WIDTH a
|
|
240
|
+
// single predicate can consume, complementing the per-node depth cap.
|
|
241
|
+
return evalNode(node, root, scope, 1, { nodes: 0 });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function evalNode(node, root, scope, depth, budget) {
|
|
245
|
+
if (++budget.nodes > PREDICATE_MAX_NODES) {
|
|
246
|
+
throw new PredicateError(
|
|
247
|
+
'node_budget',
|
|
248
|
+
`predicate exceeds the evaluation budget of ${PREDICATE_MAX_NODES} nodes`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (isCombinator(node)) {
|
|
252
|
+
if (depth > PREDICATE_MAX_DEPTH) {
|
|
253
|
+
throw new PredicateError(
|
|
254
|
+
'max_depth',
|
|
255
|
+
`predicate nests deeper than the limit of ${PREDICATE_MAX_DEPTH} combinator levels`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (Array.isArray(node.all)) return node.all.every(c => evalNode(c, root, scope, depth + 1, budget));
|
|
259
|
+
if (Array.isArray(node.any)) return node.any.some(c => evalNode(c, root, scope, depth + 1, budget));
|
|
260
|
+
if (node.not != null) return !evalNode(node.not, root, scope, depth + 1, budget);
|
|
261
|
+
// implies: [antecedent, consequent] is the VIOLATION all(antecedent, not(consequent))
|
|
262
|
+
// — it matches the inputs that BREAK the implication antecedent => consequent.
|
|
263
|
+
const [antecedent, consequent] = node.implies;
|
|
264
|
+
return evalNode(antecedent, root, scope, depth + 1, budget)
|
|
265
|
+
&& !evalNode(consequent, root, scope, depth + 1, budget);
|
|
266
|
+
}
|
|
267
|
+
return evalLeaf(node, root, scope);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Interpolate a `reason_template`'s `{slot}` placeholders from a root object.
|
|
272
|
+
* Slots are read via the same prototype-safe accessor as the field selector and
|
|
273
|
+
* interpolated RAW (no escaping) — the differential-equivalence oracle wraps
|
|
274
|
+
* values in literal quotes, and any escaping would diverge byte-for-byte. A slot
|
|
275
|
+
* that resolves to nothing renders empty.
|
|
276
|
+
*/
|
|
277
|
+
function renderTemplate(template, root) {
|
|
278
|
+
return String(template).replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, slot) => {
|
|
279
|
+
// A poison slot is rendered empty — never traversed, never thrown. The own-
|
|
280
|
+
// property read avoids the prototype chain entirely (no inherited values leak).
|
|
281
|
+
if (POISON_SEGMENTS.has(slot)) return '';
|
|
282
|
+
const present = root !== null && typeof root === 'object' && Object.hasOwn(root, slot);
|
|
283
|
+
const v = present ? root[slot] : undefined;
|
|
284
|
+
return v === undefined || v === null ? '' : String(v);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the full reason string for a matched rule: `[<id>] <body>`, where the
|
|
290
|
+
* body is the rendered `reason_template` or, absent that, the rule's
|
|
291
|
+
* `description`. The `policy:` prefix is NOT added here — verify/index.js prepends
|
|
292
|
+
* it to every policy error.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} rule - The policy rule ({ id, description?, reason_template? }).
|
|
295
|
+
* @param {object} root - The matched element (scenario_result) or submission.
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
export function buildReason(rule, root) {
|
|
299
|
+
const body = rule.reason_template != null
|
|
300
|
+
? renderTemplate(rule.reason_template, root)
|
|
301
|
+
: (rule.description ?? '');
|
|
302
|
+
return `[${rule.id}] ${body}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* ───────────────────────── Static analysis (VERIFY-F3 policy-lint) ─────────────────────────
|
|
306
|
+
*
|
|
307
|
+
* The two functions below analyze a predicate AST WITHOUT a submission. They are the reusable
|
|
308
|
+
* leaf the `policy-lint` verb (and any future eager-validation path) builds on. Co-located here,
|
|
309
|
+
* not in a separate module, so they reuse this file's private engine internals (KNOWN_FIELDS via
|
|
310
|
+
* leadingFieldFault, isCombinator, the depth/node-budget constants) without exporting the guts —
|
|
311
|
+
* predicate.js owns *everything about a predicate node*: eval AND static analysis.
|
|
312
|
+
*
|
|
313
|
+
* Coverage boundary (the VERIFY-F2 over-claim lesson — see docs/policy-lint.md):
|
|
314
|
+
* - DATA-INDEPENDENT, caught here: unknown leading field, combinator over-depth, node budget.
|
|
315
|
+
* - STRUCTURAL (unknown op, banned/malformed segment, arity): caught earlier by the schema gate
|
|
316
|
+
* (`validatePayload('policy', …)`), so lintPredicate does not re-report them.
|
|
317
|
+
* - DATA-DEPENDENT, NOT catchable statically: `type_mismatch` (a numeric op over a non-number)
|
|
318
|
+
* and `fanout_budget` (a `[]` selection size) — both need a real submission. The lint says so.
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
/** A static lint finding: a machine `code` (mirrors {@link PredicateError} codes) + an operator message. */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Statically lint a predicate tree against one scope, batch-collecting the data-independent
|
|
325
|
+
* semantic faults the schema cannot express. Unlike {@link evaluatePredicate} this NEVER throws —
|
|
326
|
+
* a lint reports every fault rather than failing on the first — and it is DEFENSIVE: a malformed
|
|
327
|
+
* node (already flagged by the schema gate) is skipped, not crashed on. The walk mirrors the
|
|
328
|
+
* evaluator's own depth + node accounting (same constants), so a lint verdict agrees with what the
|
|
329
|
+
* engine would do at eval time, and the walk is itself bounded (it cannot become a DoS on a
|
|
330
|
+
* pathological policy file).
|
|
331
|
+
*
|
|
332
|
+
* @param {unknown} node - The predicate tree (any value; non-objects are skipped).
|
|
333
|
+
* @param {'submission'|'scenario_result'} scope - Field-resolution scope.
|
|
334
|
+
* @returns {{ code: string, field?: string, message: string }[]}
|
|
335
|
+
*/
|
|
336
|
+
export function lintPredicate(node, scope) {
|
|
337
|
+
const findings = [];
|
|
338
|
+
walkLint(node, scope, 1, { nodes: 0, depthReported: false, budgetReported: false }, findings);
|
|
339
|
+
return findings;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function walkLint(node, scope, depth, state, findings) {
|
|
343
|
+
if (state.budgetReported) return;
|
|
344
|
+
if (++state.nodes > PREDICATE_MAX_NODES) {
|
|
345
|
+
if (!state.budgetReported) {
|
|
346
|
+
state.budgetReported = true;
|
|
347
|
+
findings.push({
|
|
348
|
+
code: 'node_budget',
|
|
349
|
+
message: `predicate exceeds the evaluation budget of ${PREDICATE_MAX_NODES} nodes`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (node === null || typeof node !== 'object') return;
|
|
355
|
+
|
|
356
|
+
if (isCombinator(node)) {
|
|
357
|
+
if (depth > PREDICATE_MAX_DEPTH) {
|
|
358
|
+
// Mirror the evaluator, which throws when a combinator is reached past the cap; report once
|
|
359
|
+
// and stop descending this branch (siblings are still walked — batch reporting).
|
|
360
|
+
if (!state.depthReported) {
|
|
361
|
+
state.depthReported = true;
|
|
362
|
+
findings.push({
|
|
363
|
+
code: 'max_depth',
|
|
364
|
+
message: `predicate nests deeper than the limit of ${PREDICATE_MAX_DEPTH} combinator levels`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const children =
|
|
370
|
+
Array.isArray(node.all) ? node.all :
|
|
371
|
+
Array.isArray(node.any) ? node.any :
|
|
372
|
+
node.not != null ? [node.not] :
|
|
373
|
+
Array.isArray(node.implies) ? node.implies : [];
|
|
374
|
+
for (const child of children) walkLint(child, scope, depth + 1, state, findings);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Leaf: the only data-independent semantic check is the unknown leading field. The op set and
|
|
379
|
+
// path shape are schema-gated; a numeric-op type mismatch is data-dependent (not checkable here).
|
|
380
|
+
if (typeof node.field === 'string') {
|
|
381
|
+
const fault = leadingFieldFault(node.field, scope);
|
|
382
|
+
if (fault) findings.push({ code: fault.code, field: node.field, message: fault.message });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Negative operators that fail OPEN over an empty/absent `[]` selection (the footgun). */
|
|
387
|
+
const NEGATIVE_OPS = new Set(['not_equals', 'not_in', 'not_contains', 'not_exists']);
|
|
388
|
+
|
|
389
|
+
/** The positive counterpart used in the fail-closed `not(any(...))` rewrite suggestion. */
|
|
390
|
+
const POSITIVE_OF = { not_equals: 'equals', not_in: 'in', not_contains: 'contains', not_exists: 'exists' };
|
|
391
|
+
|
|
392
|
+
/** Build the deterministic, AST-derived fail-closed rewrite suggestion for a footgun leaf. */
|
|
393
|
+
function footgunSuggestion(field, op) {
|
|
394
|
+
const pos = POSITIVE_OF[op] || 'contains';
|
|
395
|
+
const valuePart = pos === 'exists' ? ' ' : ', value: … ';
|
|
396
|
+
return `if you meant "reject when no element satisfies it", use the fail-closed idiom ` +
|
|
397
|
+
`{ not: { any: [ { field: "${field}", op: ${pos}${valuePart}} ] } }`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Find `[]`-footgun leaves: a NEGATIVE operator over a `[]` field path, which fails OPEN on an
|
|
402
|
+
* empty/absent array (existential vacuous truth — see docs/policy-lint.md). ADVISORY only: there
|
|
403
|
+
* are legitimate existential-negatives, so the caller surfaces these as warnings the author
|
|
404
|
+
* confirms, never as hard errors, and never auto-applies the rewrite.
|
|
405
|
+
*
|
|
406
|
+
* Suppression by negation PARITY, not a bare "is there a `not` above me" flag. A leaf inverted an
|
|
407
|
+
* EVEN number of times (0, 2, …) still fails open and is flagged; an ODD number of inversions makes
|
|
408
|
+
* it fail CLOSED, so it is suppressed. Two sources of inversion are counted: a `not` combinator,
|
|
409
|
+
* and the CONSEQUENT (second element) of an `implies` (since `implies:[A,C]` desugars to the
|
|
410
|
+
* violation `all(A, not(C))`). This refinement came from the VERIFY-F3 cross-family adversarial
|
|
411
|
+
* jury (deepseek-v4-pro / glm-5.2 / minimax-m3, 2026-06-30), which converged on two real defects in
|
|
412
|
+
* the original boolean rule: `not(not(X))` over `[]` fails open but was suppressed (false negative),
|
|
413
|
+
* and a negative-op consequent of `implies` fails closed but was flagged (false positive). Parity
|
|
414
|
+
* fixes both. Scope stays NEGATIVE-ops-only and ADVISORY (the jury's "flag every op" and "make it a
|
|
415
|
+
* hard error" suggestions were rejected — the former floods noise on the normal `contains` idiom,
|
|
416
|
+
* the latter blocks legitimate existential-negatives). False positives remain acceptable by design;
|
|
417
|
+
* a false negative (a silent production fail-open) is the expensive failure, so it errs toward warning.
|
|
418
|
+
*
|
|
419
|
+
* @param {unknown} node - The predicate tree.
|
|
420
|
+
* @returns {{ field: string, op: string, suggestion: string }[]}
|
|
421
|
+
*/
|
|
422
|
+
export function findEmptyArrayFootguns(node) {
|
|
423
|
+
const guns = [];
|
|
424
|
+
walkFootgun(node, false, { nodes: 0 }, guns);
|
|
425
|
+
return guns;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function walkFootgun(node, inverted, state, guns) {
|
|
429
|
+
if (node === null || typeof node !== 'object') return;
|
|
430
|
+
if (++state.nodes > PREDICATE_MAX_NODES) return; // bounded; lintPredicate reports node_budget
|
|
431
|
+
|
|
432
|
+
if (isCombinator(node)) {
|
|
433
|
+
if (Array.isArray(node.all)) { for (const c of node.all) walkFootgun(c, inverted, state, guns); return; }
|
|
434
|
+
if (Array.isArray(node.any)) { for (const c of node.any) walkFootgun(c, inverted, state, guns); return; }
|
|
435
|
+
if (node.not != null) { walkFootgun(node.not, !inverted, state, guns); return; }
|
|
436
|
+
if (Array.isArray(node.implies)) {
|
|
437
|
+
// implies:[A, C] === all(A, not(C)) — only the consequent (index 1) flips parity.
|
|
438
|
+
node.implies.forEach((c, i) => walkFootgun(c, i === 1 ? !inverted : inverted, state, guns));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!inverted &&
|
|
445
|
+
typeof node.op === 'string' && NEGATIVE_OPS.has(node.op) &&
|
|
446
|
+
typeof node.field === 'string' && node.field.includes('[]')) {
|
|
447
|
+
guns.push({ field: node.field, op: node.op, suggestion: footgunSuggestion(node.field, node.op) });
|
|
448
|
+
}
|
|
449
|
+
}
|