vigiles 15.0.0 → 15.0.1
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/dist/adapters/claude-code/run-scripts.d.ts +55 -5
- package/dist/adapters/claude-code/run-scripts.js +105 -24
- package/dist/check-count.d.ts +40 -0
- package/dist/check-count.js +136 -0
- package/dist/core/lethal-trifecta.d.ts +31 -2
- package/dist/core/lethal-trifecta.js +32 -7
- package/dist/core/skill-resources.js +41 -9
- package/dist/eval.js +6 -0
- package/dist/harness-assert.js +14 -4
- package/dist/harness-test.js +4 -0
- package/dist/run-script.js +6 -0
- package/dist/scan-core.js +56 -2
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +9 -1
- package/package.json +1 -1
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The outcome of running one script.
|
|
3
|
+
*
|
|
4
|
+
* `"vacuous"` — the script exited 0 and reported that it made ZERO checks. It
|
|
5
|
+
* neither passed nor failed: nothing was verified. See {@link statusFor}.
|
|
6
|
+
*/
|
|
7
|
+
export type ScriptStatus = "pass" | "skip" | "fail" | "vacuous";
|
|
2
8
|
export interface ScriptRunResult {
|
|
3
9
|
readonly file: string;
|
|
4
10
|
readonly code: number;
|
|
5
11
|
readonly status: ScriptStatus;
|
|
12
|
+
/**
|
|
13
|
+
* How many checks the script reported making, or `undefined` when it reported
|
|
14
|
+
* nothing at all — a script that never imports `vigiles/testing` has no way to
|
|
15
|
+
* report, and that silence is NOT a claim about it. `0` is a claim: the script
|
|
16
|
+
* loaded the library and used none of it.
|
|
17
|
+
*/
|
|
18
|
+
readonly checks?: number;
|
|
6
19
|
}
|
|
7
20
|
/**
|
|
8
21
|
* Exit code a harness/eval script uses to report itself SKIPPED (e.g. the
|
|
@@ -11,6 +24,32 @@ export interface ScriptRunResult {
|
|
|
11
24
|
* skip never fails the run. Scripts call `skip()` (vigiles/testing) to emit it.
|
|
12
25
|
*/
|
|
13
26
|
export declare const SKIP_EXIT_CODE = 77;
|
|
27
|
+
/**
|
|
28
|
+
* Classify one script's run from its exit code and its reported check count.
|
|
29
|
+
*
|
|
30
|
+
* 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
|
|
31
|
+
* do anything?". Measured 2026-08-08: a file whose whole body is
|
|
32
|
+
* `export default { "never runs": () => assert.equal(1, 2) }` imports fine,
|
|
33
|
+
* exits 0, and printed `✓ … 1 passed` — a false assertion, never called,
|
|
34
|
+
* reported as a pass. A consumer repo hit exactly that and now hand-copies a
|
|
35
|
+
* warning into every new harness header, because the runner could not enforce
|
|
36
|
+
* it: eight harnesses resting on a comment.
|
|
37
|
+
*
|
|
38
|
+
* So a run that ends clean having recorded ZERO checks is `"vacuous"` — its own
|
|
39
|
+
* visible state, not folded into `passed`, the same way a skip is not.
|
|
40
|
+
*
|
|
41
|
+
* NOT A FAILURE, deliberately. Harnesses in the wild predate the counter, and a
|
|
42
|
+
* tool that turned CI red on the release that taught it a new word would be
|
|
43
|
+
* punishing people for upgrading. It is loud and it is not fatal.
|
|
44
|
+
*
|
|
45
|
+
* AND SILENCE IS NOT ZERO. `checks === undefined` means the script never
|
|
46
|
+
* reported — it may not import `vigiles/testing` at all — so it stays a plain
|
|
47
|
+
* `pass`, exactly as before. Only a script that loaded the library and used
|
|
48
|
+
* none of it says zero. This is the `undefined`-vs-`[]` distinction
|
|
49
|
+
* `assertNoWrite` already draws: "nobody looked" must not read as "nothing
|
|
50
|
+
* happened".
|
|
51
|
+
*/
|
|
52
|
+
export declare function statusFor(code: number, checks: number | undefined): ScriptStatus;
|
|
14
53
|
/** Filename extensions accepted for harness/eval scripts (JS and TS). */
|
|
15
54
|
export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
|
|
16
55
|
/** Glob suffix matching every accepted script extension, e.g. `harness`. */
|
|
@@ -41,10 +80,20 @@ export declare function discoverScripts(patterns: readonly string[], defaultGlob
|
|
|
41
80
|
/**
|
|
42
81
|
* Run each script as `node <file>`, inheriting stdio so the script's own report
|
|
43
82
|
* streams to the console. `env` is merged over `process.env` for every child
|
|
44
|
-
* (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
|
|
83
|
+
* (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes + check counts.
|
|
84
|
+
*
|
|
85
|
+
* Each child is handed its OWN scratch path in `VIGILES_CHECK_COUNT_ENV`, which
|
|
86
|
+
* `vigiles/testing` writes its check count to on exit — the channel that makes
|
|
87
|
+
* "ran nothing" distinguishable from "ran and passed" (see check-count.ts). It
|
|
88
|
+
* has to be a file: stdio is inherited so the script's report streams live,
|
|
89
|
+
* which leaves no stream to parse.
|
|
45
90
|
*/
|
|
46
91
|
export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv): ScriptRunResult[];
|
|
47
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
|
|
94
|
+
* declined to run, the second ran and verified nothing, and neither is evidence
|
|
95
|
+
* that anything is broken. Both are visible in the summary instead.
|
|
96
|
+
*/
|
|
48
97
|
export declare function anyFailed(results: readonly ScriptRunResult[]): boolean;
|
|
49
98
|
/**
|
|
50
99
|
* What a `test`/`eval` invocation should do about actually RUNNING the discovered
|
|
@@ -87,7 +136,8 @@ export interface RunScriptsEnv {
|
|
|
87
136
|
* CLI.
|
|
88
137
|
*/
|
|
89
138
|
export declare function decideRunScripts(o: RunScriptsEnv): RunScriptsDecision;
|
|
90
|
-
/** One line per file + an explicit pass/skip/fail tally. Skips
|
|
91
|
-
* folded into "passed" — a `⊘ SKIPPED` is loud,
|
|
139
|
+
/** One line per file + an explicit pass/skip/vacuous/fail tally. Skips and
|
|
140
|
+
* vacuous runs are SHOWN, never folded into "passed" — a `⊘ SKIPPED` is loud,
|
|
141
|
+
* not a silent green, and so is a file that verified nothing. */
|
|
92
142
|
export declare function formatScriptSummary(results: readonly ScriptRunResult[]): string;
|
|
93
143
|
//# sourceMappingURL=run-scripts.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SCRIPT_EXTS = exports.SKIP_EXIT_CODE = void 0;
|
|
4
|
+
exports.statusFor = statusFor;
|
|
4
5
|
exports.scriptGlob = scriptGlob;
|
|
5
6
|
exports.interpreterArgs = interpreterArgs;
|
|
6
7
|
exports.detectNodeCaps = detectNodeCaps;
|
|
@@ -24,7 +25,9 @@ exports.formatScriptSummary = formatScriptSummary;
|
|
|
24
25
|
const node_child_process_1 = require("node:child_process");
|
|
25
26
|
const node_path_1 = require("node:path");
|
|
26
27
|
const node_fs_1 = require("node:fs");
|
|
28
|
+
const node_os_1 = require("node:os");
|
|
27
29
|
const glob_1 = require("glob");
|
|
30
|
+
const check_count_js_1 = require("../../check-count.js");
|
|
28
31
|
/**
|
|
29
32
|
* Exit code a harness/eval script uses to report itself SKIPPED (e.g. the
|
|
30
33
|
* deterministic tier when `claude` isn't installed) — the autotools convention.
|
|
@@ -32,12 +35,37 @@ const glob_1 = require("glob");
|
|
|
32
35
|
* skip never fails the run. Scripts call `skip()` (vigiles/testing) to emit it.
|
|
33
36
|
*/
|
|
34
37
|
exports.SKIP_EXIT_CODE = 77;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Classify one script's run from its exit code and its reported check count.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
|
|
42
|
+
* do anything?". Measured 2026-08-08: a file whose whole body is
|
|
43
|
+
* `export default { "never runs": () => assert.equal(1, 2) }` imports fine,
|
|
44
|
+
* exits 0, and printed `✓ … 1 passed` — a false assertion, never called,
|
|
45
|
+
* reported as a pass. A consumer repo hit exactly that and now hand-copies a
|
|
46
|
+
* warning into every new harness header, because the runner could not enforce
|
|
47
|
+
* it: eight harnesses resting on a comment.
|
|
48
|
+
*
|
|
49
|
+
* So a run that ends clean having recorded ZERO checks is `"vacuous"` — its own
|
|
50
|
+
* visible state, not folded into `passed`, the same way a skip is not.
|
|
51
|
+
*
|
|
52
|
+
* NOT A FAILURE, deliberately. Harnesses in the wild predate the counter, and a
|
|
53
|
+
* tool that turned CI red on the release that taught it a new word would be
|
|
54
|
+
* punishing people for upgrading. It is loud and it is not fatal.
|
|
55
|
+
*
|
|
56
|
+
* AND SILENCE IS NOT ZERO. `checks === undefined` means the script never
|
|
57
|
+
* reported — it may not import `vigiles/testing` at all — so it stays a plain
|
|
58
|
+
* `pass`, exactly as before. Only a script that loaded the library and used
|
|
59
|
+
* none of it says zero. This is the `undefined`-vs-`[]` distinction
|
|
60
|
+
* `assertNoWrite` already draws: "nobody looked" must not read as "nothing
|
|
61
|
+
* happened".
|
|
62
|
+
*/
|
|
63
|
+
function statusFor(code, checks) {
|
|
38
64
|
if (code === exports.SKIP_EXIT_CODE)
|
|
39
65
|
return "skip";
|
|
40
|
-
|
|
66
|
+
if (code !== 0)
|
|
67
|
+
return "fail";
|
|
68
|
+
return checks === 0 ? "vacuous" : "pass";
|
|
41
69
|
}
|
|
42
70
|
/** Filename extensions accepted for harness/eval scripts (JS and TS). */
|
|
43
71
|
exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
|
|
@@ -104,35 +132,73 @@ function discoverScripts(patterns, defaultGlob, cwd) {
|
|
|
104
132
|
}
|
|
105
133
|
return [...found].sort();
|
|
106
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* The count a script left behind, or `undefined` if it left none (it never
|
|
137
|
+
* imported `vigiles/testing`, or died before its exit handler). Anything that
|
|
138
|
+
* isn't a non-negative integer is treated as no report — a corrupt scratch file
|
|
139
|
+
* must not invent a verdict.
|
|
140
|
+
*/
|
|
141
|
+
function readCheckCount(path) {
|
|
142
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
143
|
+
return undefined;
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = (0, node_fs_1.readFileSync)(path, "utf8").trim();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (!/^\d+$/.test(raw))
|
|
152
|
+
return undefined;
|
|
153
|
+
return Number(raw);
|
|
154
|
+
}
|
|
107
155
|
/**
|
|
108
156
|
* Run each script as `node <file>`, inheriting stdio so the script's own report
|
|
109
157
|
* streams to the console. `env` is merged over `process.env` for every child
|
|
110
|
-
* (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
|
|
158
|
+
* (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes + check counts.
|
|
159
|
+
*
|
|
160
|
+
* Each child is handed its OWN scratch path in `VIGILES_CHECK_COUNT_ENV`, which
|
|
161
|
+
* `vigiles/testing` writes its check count to on exit — the channel that makes
|
|
162
|
+
* "ran nothing" distinguishable from "ran and passed" (see check-count.ts). It
|
|
163
|
+
* has to be a file: stdio is inherited so the script's report streams live,
|
|
164
|
+
* which leaves no stream to parse.
|
|
111
165
|
*/
|
|
112
166
|
function runScripts(files, cwd, env = {}) {
|
|
113
167
|
const caps = detectNodeCaps(cwd);
|
|
114
168
|
const results = [];
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
argv
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
169
|
+
const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-checks-"));
|
|
170
|
+
try {
|
|
171
|
+
files.forEach((file, i) => {
|
|
172
|
+
let argv;
|
|
173
|
+
try {
|
|
174
|
+
argv = interpreterArgs(file, caps);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
console.error(`✗ ${file}: ${e.message}`);
|
|
178
|
+
results.push({ file, code: 1, status: "fail" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
|
|
182
|
+
const res = (0, node_child_process_1.spawnSync)("node", argv, {
|
|
183
|
+
cwd,
|
|
184
|
+
stdio: "inherit",
|
|
185
|
+
env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
|
|
186
|
+
});
|
|
187
|
+
const code = res.status ?? 1;
|
|
188
|
+
const checks = readCheckCount(countFile);
|
|
189
|
+
results.push({ file, code, status: statusFor(code, checks), checks });
|
|
129
190
|
});
|
|
130
|
-
|
|
131
|
-
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
(0, node_fs_1.rmSync)(countDir, { recursive: true, force: true });
|
|
132
194
|
}
|
|
133
195
|
return results;
|
|
134
196
|
}
|
|
135
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
|
|
199
|
+
* declined to run, the second ran and verified nothing, and neither is evidence
|
|
200
|
+
* that anything is broken. Both are visible in the summary instead.
|
|
201
|
+
*/
|
|
136
202
|
function anyFailed(results) {
|
|
137
203
|
return results.some((r) => r.status === "fail");
|
|
138
204
|
}
|
|
@@ -165,24 +231,39 @@ const MARK = {
|
|
|
165
231
|
pass: "✓",
|
|
166
232
|
skip: "⊘",
|
|
167
233
|
fail: "✗",
|
|
234
|
+
vacuous: "∅",
|
|
168
235
|
};
|
|
169
|
-
/** One line per file + an explicit pass/skip/fail tally. Skips
|
|
170
|
-
* folded into "passed" — a `⊘ SKIPPED` is loud,
|
|
236
|
+
/** One line per file + an explicit pass/skip/vacuous/fail tally. Skips and
|
|
237
|
+
* vacuous runs are SHOWN, never folded into "passed" — a `⊘ SKIPPED` is loud,
|
|
238
|
+
* not a silent green, and so is a file that verified nothing. */
|
|
171
239
|
function formatScriptSummary(results) {
|
|
172
240
|
const lines = results.map((r) => {
|
|
173
241
|
if (r.status === "skip")
|
|
174
242
|
return ` ⊘ ${r.file} — SKIPPED`;
|
|
175
243
|
if (r.status === "fail")
|
|
176
244
|
return ` ✗ ${r.file} (exit ${String(r.code)})`;
|
|
245
|
+
if (r.status === "vacuous") {
|
|
246
|
+
return ` ${MARK.vacuous} ${r.file} — 0 CHECKS (it ran clean and verified nothing)`;
|
|
247
|
+
}
|
|
177
248
|
return ` ${MARK.pass} ${r.file}`;
|
|
178
249
|
});
|
|
179
250
|
const n = (s) => results.filter((r) => r.status === s).length;
|
|
180
251
|
const parts = [`${String(n("pass"))} passed`];
|
|
181
252
|
if (n("skip") > 0)
|
|
182
253
|
parts.push(`${String(n("skip"))} skipped`);
|
|
254
|
+
if (n("vacuous") > 0)
|
|
255
|
+
parts.push(`${String(n("vacuous"))} with 0 checks`);
|
|
183
256
|
if (n("fail") > 0)
|
|
184
257
|
parts.push(`${String(n("fail"))} failed`);
|
|
185
258
|
lines.push(`\n${parts.join(", ")}.`);
|
|
259
|
+
// Name the remedy where it's read, once — the usual cause is a file that
|
|
260
|
+
// DEFINES tests and never calls them, and the usual second cause is a harness
|
|
261
|
+
// asserting some other way, which the runner cannot see.
|
|
262
|
+
if (n("vacuous") > 0) {
|
|
263
|
+
lines.push(` ∅ = the file loaded vigiles/testing and used none of it. Either nothing ran ` +
|
|
264
|
+
`(an exported test object nobody calls), or it asserts another way — in which ` +
|
|
265
|
+
`case call recordCheck() from vigiles/testing so those count.`);
|
|
266
|
+
}
|
|
186
267
|
return lines.join("\n");
|
|
187
268
|
}
|
|
188
269
|
//# sourceMappingURL=run-scripts.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env var naming the file a script writes its check count to. Set per-script by
|
|
3
|
+
* the runner (`runScripts`), read once here at import.
|
|
4
|
+
*/
|
|
5
|
+
export declare const CHECK_COUNT_ENV = "VIGILES_CHECK_COUNT_FILE";
|
|
6
|
+
/**
|
|
7
|
+
* Record `n` checks against this script's run.
|
|
8
|
+
*
|
|
9
|
+
* The tiers call it themselves, so an ordinary harness never needs to. Call it
|
|
10
|
+
* directly when you assert some OTHER way — `node:assert`, vitest's `expect`, a
|
|
11
|
+
* hand-rolled comparison — and want those visible to `vigiles test` instead of
|
|
12
|
+
* leaving it to conclude the file did nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function recordCheck(n?: number): void;
|
|
15
|
+
/** How many checks this process has recorded so far. */
|
|
16
|
+
export declare function checksRecorded(): number;
|
|
17
|
+
/**
|
|
18
|
+
* Reset the counter AND the armed flag. For vigiles's own tests, which drive
|
|
19
|
+
* {@link armCheckReport} with fakes several times in one process; a harness
|
|
20
|
+
* script has no use for it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resetCheckCount(): void;
|
|
23
|
+
/** Injection seam for {@link armCheckReport} — the process bits it needs. */
|
|
24
|
+
export interface CheckReportEnv {
|
|
25
|
+
readonly env: NodeJS.ProcessEnv;
|
|
26
|
+
readonly onExit: (fn: () => void) => void;
|
|
27
|
+
readonly write: (path: string, contents: string) => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Arm the exit-time report, returning whether it armed (i.e. whether the runner
|
|
31
|
+
* asked for a count). Pure except for the two effects it is handed.
|
|
32
|
+
*
|
|
33
|
+
* It DELETES the env var after reading it. A harness spawns child processes —
|
|
34
|
+
* that is its whole job — and a child inheriting the path would write ITS count
|
|
35
|
+
* over the parent's on exit, reporting a sub-process's activity as the file's.
|
|
36
|
+
* Reading the variable once and dropping it makes that unrepresentable rather
|
|
37
|
+
* than merely unlikely.
|
|
38
|
+
*/
|
|
39
|
+
export declare function armCheckReport(deps: CheckReportEnv): boolean;
|
|
40
|
+
//# sourceMappingURL=check-count.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CHECK_COUNT_ENV = void 0;
|
|
4
|
+
exports.recordCheck = recordCheck;
|
|
5
|
+
exports.checksRecorded = checksRecorded;
|
|
6
|
+
exports.resetCheckCount = resetCheckCount;
|
|
7
|
+
exports.armCheckReport = armCheckReport;
|
|
8
|
+
/**
|
|
9
|
+
* vigiles — the CHECK COUNTER: the channel a `*.harness.*` / `*.eval.*` script
|
|
10
|
+
* uses to tell `vigiles test` / `vigiles eval` how much it actually did.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 WHY THIS EXISTS. The runner knew exit codes and nothing else, so a script
|
|
13
|
+
* that ran NOTHING was indistinguishable from one that ran and passed. Measured
|
|
14
|
+
* 2026-08-08 — a file whose entire content is
|
|
15
|
+
*
|
|
16
|
+
* export default { "never runs": () => assert.equal(1, 2) };
|
|
17
|
+
*
|
|
18
|
+
* imports cleanly, exits 0, and the runner printed `✓ … 1 passed`. The assertion
|
|
19
|
+
* inside it is false; it is never called, because nothing calls an exported
|
|
20
|
+
* object. Not hypothetical: a consumer repo hit it, and now hand-copies a warning
|
|
21
|
+
* into the header of every new harness file — "An earlier version exported a
|
|
22
|
+
* `tests` object; nothing ran and the runner printed ✓" — because the runner
|
|
23
|
+
* could not enforce it. Eight harnesses resting on a comment.
|
|
24
|
+
*
|
|
25
|
+
* This is the same distinction `assertNoWrite` already draws between `undefined`
|
|
26
|
+
* and `[]`: "nobody looked" and "nothing happened" must not print the same.
|
|
27
|
+
*
|
|
28
|
+
* HOW IT REPORTS. `vigiles test` puts a scratch path in `VIGILES_CHECK_COUNT_FILE`
|
|
29
|
+
* before spawning each script; on exit, a script that loaded this module writes
|
|
30
|
+
* its count there. A FILE, not stdout, because the runner inherits stdio so the
|
|
31
|
+
* script's own report streams live — there is no stream left to parse. Not an
|
|
32
|
+
* exit code either: those already carry pass/skip/fail, and overloading one would
|
|
33
|
+
* make a real failure ambiguous.
|
|
34
|
+
*
|
|
35
|
+
* WHAT COUNTS AS A CHECK: an observation vigiles can see the script make — a run
|
|
36
|
+
* through one of the tiers (`runHook`, `runHarnessTest`, `runEval`, …), an
|
|
37
|
+
* in-process compiled-hook decision asserted, or an explicit {@link recordCheck}
|
|
38
|
+
* from a harness that asserts some other way (`node:assert`, a runner's
|
|
39
|
+
* `expect`). Deliberately generous: the question this answers is "did this file
|
|
40
|
+
* exercise the harness at all", and a false "0 checks" against a script that
|
|
41
|
+
* genuinely tested something would be exactly the crying wolf the rest of the
|
|
42
|
+
* tool avoids.
|
|
43
|
+
*
|
|
44
|
+
* WHAT A MISSING COUNT MEANS: nothing at all. A script that never imports
|
|
45
|
+
* `vigiles/testing` cannot report, so the runner sees no file and treats it
|
|
46
|
+
* exactly as before — a plain pass. Silence is the legacy branch, never a
|
|
47
|
+
* verdict; only a count of literally zero is a finding. (The alternative —
|
|
48
|
+
* force-loading this module into every child with `node --import` so silence
|
|
49
|
+
* became impossible — was rejected: it would report `0` for a hand-rolled
|
|
50
|
+
* harness that spawns and asserts entirely on its own, which is a real and
|
|
51
|
+
* blameless way to write one.)
|
|
52
|
+
*/
|
|
53
|
+
const node_fs_1 = require("node:fs");
|
|
54
|
+
/**
|
|
55
|
+
* Env var naming the file a script writes its check count to. Set per-script by
|
|
56
|
+
* the runner (`runScripts`), read once here at import.
|
|
57
|
+
*/
|
|
58
|
+
exports.CHECK_COUNT_ENV = "VIGILES_CHECK_COUNT_FILE";
|
|
59
|
+
/**
|
|
60
|
+
* The counter lives on the global registry, not in module scope, so two copies
|
|
61
|
+
* of vigiles loaded in one child (a global CLI plus a local dependency, say)
|
|
62
|
+
* share ONE count instead of one copy counting while the other reports zero.
|
|
63
|
+
* The cheap version of that bug is a false "this file verified nothing".
|
|
64
|
+
*/
|
|
65
|
+
const STATE = Symbol.for("vigiles.check-count");
|
|
66
|
+
function state() {
|
|
67
|
+
const g = globalThis;
|
|
68
|
+
return (g[STATE] ??= { count: 0, armed: false });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Record `n` checks against this script's run.
|
|
72
|
+
*
|
|
73
|
+
* The tiers call it themselves, so an ordinary harness never needs to. Call it
|
|
74
|
+
* directly when you assert some OTHER way — `node:assert`, vitest's `expect`, a
|
|
75
|
+
* hand-rolled comparison — and want those visible to `vigiles test` instead of
|
|
76
|
+
* leaving it to conclude the file did nothing.
|
|
77
|
+
*/
|
|
78
|
+
function recordCheck(n = 1) {
|
|
79
|
+
state().count += n;
|
|
80
|
+
}
|
|
81
|
+
/** How many checks this process has recorded so far. */
|
|
82
|
+
function checksRecorded() {
|
|
83
|
+
return state().count;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reset the counter AND the armed flag. For vigiles's own tests, which drive
|
|
87
|
+
* {@link armCheckReport} with fakes several times in one process; a harness
|
|
88
|
+
* script has no use for it.
|
|
89
|
+
*/
|
|
90
|
+
function resetCheckCount() {
|
|
91
|
+
const s = state();
|
|
92
|
+
s.count = 0;
|
|
93
|
+
s.armed = false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Arm the exit-time report, returning whether it armed (i.e. whether the runner
|
|
97
|
+
* asked for a count). Pure except for the two effects it is handed.
|
|
98
|
+
*
|
|
99
|
+
* It DELETES the env var after reading it. A harness spawns child processes —
|
|
100
|
+
* that is its whole job — and a child inheriting the path would write ITS count
|
|
101
|
+
* over the parent's on exit, reporting a sub-process's activity as the file's.
|
|
102
|
+
* Reading the variable once and dropping it makes that unrepresentable rather
|
|
103
|
+
* than merely unlikely.
|
|
104
|
+
*/
|
|
105
|
+
function armCheckReport(deps) {
|
|
106
|
+
const s = state();
|
|
107
|
+
if (s.armed)
|
|
108
|
+
return false;
|
|
109
|
+
const file = deps.env[exports.CHECK_COUNT_ENV];
|
|
110
|
+
if (file === undefined || file === "")
|
|
111
|
+
return false;
|
|
112
|
+
// `Reflect.deleteProperty`, not `delete env[KEY]`: the key is a const, which
|
|
113
|
+
// the lint rules count as a dynamic delete.
|
|
114
|
+
Reflect.deleteProperty(deps.env, exports.CHECK_COUNT_ENV);
|
|
115
|
+
s.armed = true;
|
|
116
|
+
deps.onExit(() => {
|
|
117
|
+
try {
|
|
118
|
+
deps.write(file, String(s.count));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// An unwritable scratch path must never turn a passing harness into a
|
|
122
|
+
// crash on the way out. No count reported = the legacy branch = a pass.
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
armCheckReport({
|
|
128
|
+
env: process.env,
|
|
129
|
+
onExit: (fn) => {
|
|
130
|
+
process.on("exit", fn);
|
|
131
|
+
},
|
|
132
|
+
write: (path, contents) => {
|
|
133
|
+
(0, node_fs_1.writeFileSync)(path, contents);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
//# sourceMappingURL=check-count.js.map
|
|
@@ -86,16 +86,45 @@ export declare function bashGrantIsUnbounded(raw: string): boolean;
|
|
|
86
86
|
* by {@link lethalTrifectaIssues}, which knows it grants every leg.
|
|
87
87
|
*/
|
|
88
88
|
export declare function classifyTrifectaLegs(tools: readonly string[], dialect: HarnessDialect): TrifectaLegs;
|
|
89
|
+
/** Extra facts about HOW the contract was obtained, which change the verdict. */
|
|
90
|
+
export interface TrifectaContext {
|
|
91
|
+
/**
|
|
92
|
+
* The unit's frontmatter block EXISTS but is not valid YAML.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 WHY THE DETECTOR NEEDS TO KNOW. vigiles's frontmatter reader is
|
|
95
|
+
* deliberately lenient: on a block js-yaml rejects it regex-SALVAGES the
|
|
96
|
+
* fields, so the live PreToolUse rail still has something to enforce. Right
|
|
97
|
+
* for a rail, wrong for a score. Measured 2026-08-08:
|
|
98
|
+
* `readFrontmatter(bad).malformed` is `true` — the tool KNOWS the block is
|
|
99
|
+
* broken — while `frontmatterList(…, "allowed-tools")` on it still returns
|
|
100
|
+
* `["Read","Bash"]`, the narrow contract its author MEANT. A unit whose
|
|
101
|
+
* contract a strict loader rejects was therefore graded as though it had
|
|
102
|
+
* declared exactly that list, and scored CLEAN. Presence of a declaration is
|
|
103
|
+
* not enforcement of it — the product's own thesis, turned on the product.
|
|
104
|
+
*
|
|
105
|
+
* With this set, `tools` is read as a SALVAGE, not a contract: it can only make
|
|
106
|
+
* the verdict worse, never better. A salvaged list that names all three legs
|
|
107
|
+
* still fires `"hard"` (both readings of the file agree the unit holds them);
|
|
108
|
+
* anything less falls back to what a strict loader actually yields — no
|
|
109
|
+
* contract at all, i.e. inherits-all, which is the `"advisory"` finding.
|
|
110
|
+
*/
|
|
111
|
+
readonly contractUnreadable?: boolean;
|
|
112
|
+
}
|
|
89
113
|
/**
|
|
90
114
|
* Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
|
|
91
115
|
* `null` (≤ 2 legs = safe by the Rule of Two).
|
|
92
116
|
*
|
|
93
|
-
*
|
|
117
|
+
* Three paths:
|
|
94
118
|
* - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
|
|
95
119
|
* tool → trivially all three legs → an `"advisory"` finding (the inherits-all
|
|
96
120
|
* stance: a footgun worth surfacing, not a declared exfil path).
|
|
97
121
|
* - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
|
|
98
122
|
* three legs is non-empty.
|
|
123
|
+
* - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
|
|
124
|
+
* a salvage of a block a strict loader rejects. They can only make the verdict
|
|
125
|
+
* WORSE — a salvaged all-three still fires `"hard"` — and anything short of
|
|
126
|
+
* that falls back to what a strict loader really yields: no contract, i.e.
|
|
127
|
+
* inherits-all, the `"advisory"` finding. Never the other way round.
|
|
99
128
|
*/
|
|
100
|
-
export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect): TrifectaFinding | null;
|
|
129
|
+
export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect, ctx?: TrifectaContext): TrifectaFinding | null;
|
|
101
130
|
//# sourceMappingURL=lethal-trifecta.d.ts.map
|
|
@@ -249,14 +249,19 @@ function classifyTrifectaLegs(tools, dialect) {
|
|
|
249
249
|
* Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
|
|
250
250
|
* `null` (≤ 2 legs = safe by the Rule of Two).
|
|
251
251
|
*
|
|
252
|
-
*
|
|
252
|
+
* Three paths:
|
|
253
253
|
* - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
|
|
254
254
|
* tool → trivially all three legs → an `"advisory"` finding (the inherits-all
|
|
255
255
|
* stance: a footgun worth surfacing, not a declared exfil path).
|
|
256
256
|
* - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
|
|
257
257
|
* three legs is non-empty.
|
|
258
|
+
* - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
|
|
259
|
+
* a salvage of a block a strict loader rejects. They can only make the verdict
|
|
260
|
+
* WORSE — a salvaged all-three still fires `"hard"` — and anything short of
|
|
261
|
+
* that falls back to what a strict loader really yields: no contract, i.e.
|
|
262
|
+
* inherits-all, the `"advisory"` finding. Never the other way round.
|
|
258
263
|
*/
|
|
259
|
-
function lethalTrifectaIssues(tools, dialect) {
|
|
264
|
+
function lethalTrifectaIssues(tools, dialect, ctx = {}) {
|
|
260
265
|
const hasWildcard = tools.some((t) => isWildcard(baseTool(t)));
|
|
261
266
|
// Inherits-all is signalled by a WILDCARD (the caller passes `["*"]` for an
|
|
262
267
|
// absent `tools:` line). An EXPLICIT empty `[]` is the opposite — zero tools,
|
|
@@ -271,10 +276,17 @@ function lethalTrifectaIssues(tools, dialect) {
|
|
|
271
276
|
return {
|
|
272
277
|
severity: "advisory",
|
|
273
278
|
legs,
|
|
274
|
-
message:
|
|
275
|
-
"
|
|
276
|
-
|
|
277
|
-
|
|
279
|
+
message: ctx.contractUnreadable
|
|
280
|
+
? "Frontmatter is not valid YAML, so the declared tool list could not be read — a " +
|
|
281
|
+
"strict loader rejects the block, and a regex salvage of it is a guess, not a " +
|
|
282
|
+
"contract. Scored as INHERITS-ALL (every capability), which is what a strict " +
|
|
283
|
+
"loader yields: it therefore holds all three lethal-trifecta legs (read private " +
|
|
284
|
+
"data, ingest untrusted content, exfiltrate). Fix the YAML and the declared list " +
|
|
285
|
+
"counts again — a declaration that does not parse is not an enforcement."
|
|
286
|
+
: "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
|
|
287
|
+
"it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
|
|
288
|
+
"exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
|
|
289
|
+
"list dropping at least one leg (Meta's Rule of Two).",
|
|
278
290
|
};
|
|
279
291
|
}
|
|
280
292
|
const legs = classifyTrifectaLegs(tools, dialect);
|
|
@@ -288,9 +300,22 @@ function lethalTrifectaIssues(tools, dialect) {
|
|
|
288
300
|
`(${legs.private.join(", ")}), ingest untrusted content ` +
|
|
289
301
|
`(${legs.untrusted.join(", ")}), AND exfiltrate ` +
|
|
290
302
|
`(${legs.exfil.join(", ")}) — a prompt-injection exfil path with no exploit code. ` +
|
|
291
|
-
"Drop at least one leg (Meta's Rule of Two: allow at most two)."
|
|
303
|
+
"Drop at least one leg (Meta's Rule of Two: allow at most two)." +
|
|
304
|
+
(ctx.contractUnreadable
|
|
305
|
+
? " (Those tool names were SALVAGED: the frontmatter is not valid YAML, so a strict" +
|
|
306
|
+
" loader reads no contract here at all and the real grant may be wider still." +
|
|
307
|
+
" Fix the YAML — this finding stands either way.)"
|
|
308
|
+
: ""),
|
|
292
309
|
};
|
|
293
310
|
}
|
|
311
|
+
// Fewer than three legs — but the names were salvaged from a block a strict
|
|
312
|
+
// loader rejects, so "fewer" is a guess. What that loader actually yields is
|
|
313
|
+
// NOTHING: no contract, which is inherits-all, which holds every leg. This is
|
|
314
|
+
// the branch the defect lived in — a malformed unit scored clean because the
|
|
315
|
+
// salvage happened to read narrow.
|
|
316
|
+
if (ctx.contractUnreadable) {
|
|
317
|
+
return lethalTrifectaIssues(["*"], dialect, ctx);
|
|
318
|
+
}
|
|
294
319
|
return null;
|
|
295
320
|
}
|
|
296
321
|
//# sourceMappingURL=lethal-trifecta.js.map
|
|
@@ -33,8 +33,9 @@ exports.skillResourceIssues = skillResourceIssues;
|
|
|
33
33
|
* `references/finance.md`", or a markdown-link example demonstrating how to
|
|
34
34
|
* write a path — e.g. one with a space in the filename). A reference (link OR
|
|
35
35
|
* inline path) is treated as real ONLY when the line DIRECTS the agent to use
|
|
36
|
-
* the file (read/run/see
|
|
37
|
-
*
|
|
36
|
+
* the file (read/run/see/…, or the line is a HEADING naming it — see
|
|
37
|
+
* `MD_HEADING`) and carries no illustrative cue (example / e.g. / such as /
|
|
38
|
+
* would be / template / →). See `inlinePathIsUsed`.
|
|
38
39
|
*
|
|
39
40
|
* ESCAPE HATCH: a SKILL.md carrying `<!-- vigiles-disable skill-resource-resolves -->`
|
|
40
41
|
* anywhere in its body opts OUT of this check entirely (mirrors `orphans.ts`'s
|
|
@@ -179,18 +180,49 @@ const USE_DIRECTIVE = /\b(run|runs|execute|executes|read|reads|load|loads|open|o
|
|
|
179
180
|
// pointing at a shipped file. Includes the `→`/`->` arrow used in "move detail
|
|
180
181
|
// → `references/x.md`" authoring lists.
|
|
181
182
|
const ILLUSTRATIVE_CUE = /\b(example|examples|e\.g\.?|i\.e\.?|such as|for instance|would be|helpful|useful|template|boilerplate)\b|→|->/i;
|
|
183
|
+
/**
|
|
184
|
+
* An ATX markdown heading (`## …`, up to three leading spaces per CommonMark).
|
|
185
|
+
*
|
|
186
|
+
* 🔴 WHY A HEADING COUNTS AS A USE-DIRECTIVE. The verb gate below reads PROSE,
|
|
187
|
+
* and a heading is not prose — it is the section's label. So the single most
|
|
188
|
+
* common way a skill points at its own bundled script, naming it in the heading
|
|
189
|
+
* of the section about running it, carried no verb and went unchecked:
|
|
190
|
+
*
|
|
191
|
+
* ## 🏗 START WITH THE MECHANICAL LEG — `scripts/structure.mjs`
|
|
192
|
+
*
|
|
193
|
+
* Measured 2026-08-08 on a real skill whose `structure.mjs` sits at the skill
|
|
194
|
+
* ROOT, not under `scripts/`: that line yielded NOTHING. Rewriting the same
|
|
195
|
+
* heading to "Run the mechanical leg" — same file, same ref, same missing
|
|
196
|
+
* target — correctly yielded the finding. The tool's answer depended on the
|
|
197
|
+
* author's choice of verb, and it stayed silent for three days.
|
|
198
|
+
*
|
|
199
|
+
* The narrow fix, and deliberately not the wide one. The alternative — treat
|
|
200
|
+
* ANY bundle-dir path with an extension as a reference, verb or not — reopens
|
|
201
|
+
* exactly the false positives the gate exists for (a skill TEACHING how to
|
|
202
|
+
* build skills mentions `scripts/rotate.py` constantly as prose). A heading
|
|
203
|
+
* naming a bundle path is a structural claim about what this section is about,
|
|
204
|
+
* not a sentence illustrating what a skill *could* ship, so it earns the same
|
|
205
|
+
* standing as an explicit "run …". The illustrative-cue veto still applies —
|
|
206
|
+
* `## Examples: \`references/finance.md\`` stays skipped — and the check is
|
|
207
|
+
* `warn` severity with a `vigiles-disable` escape hatch, so a slightly wider
|
|
208
|
+
* net is affordable where a blanket one is not.
|
|
209
|
+
*/
|
|
210
|
+
const MD_HEADING = /^\s{0,3}#{1,6}\s/;
|
|
182
211
|
/**
|
|
183
212
|
* Whether a bundle-path reference on this line reads as a REAL reference (the
|
|
184
213
|
* agent is told to use the file) rather than an illustrative mention. Requires
|
|
185
|
-
* a positive
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
214
|
+
* a positive directive — a use verb, or the line being a heading (see
|
|
215
|
+
* {@link MD_HEADING}) — and the absence of an illustrative cue, both evaluated
|
|
216
|
+
* over the whole line for simplicity (a tight, precise rule over a clever one).
|
|
217
|
+
* BOTH candidate shapes consult this (issue #110) — a markdown link's
|
|
218
|
+
* `[text](target)` syntax is a stronger signal than a bare inline span, but
|
|
219
|
+
* "For example, see [the schema](...)" is still an illustrative mention, not a
|
|
220
|
+
* real dead ref.
|
|
191
221
|
*/
|
|
192
222
|
function inlinePathIsUsed(line) {
|
|
193
|
-
|
|
223
|
+
if (ILLUSTRATIVE_CUE.test(line))
|
|
224
|
+
return false;
|
|
225
|
+
return USE_DIRECTIVE.test(line) || MD_HEADING.test(line);
|
|
194
226
|
}
|
|
195
227
|
/** Collect candidate bundled-resource refs from one body line, skipping fences. */
|
|
196
228
|
function candidatesInLine(line, lineNo) {
|
package/dist/eval.js
CHANGED
|
@@ -75,6 +75,7 @@ const harness_test_js_1 = require("./harness-test.js");
|
|
|
75
75
|
const eval_cache_js_1 = require("./eval-cache.js");
|
|
76
76
|
const eval_lock_js_1 = require("./eval-lock.js");
|
|
77
77
|
const stats_js_1 = require("./stats.js");
|
|
78
|
+
const check_count_js_1 = require("./check-count.js");
|
|
78
79
|
const tool_intercept_js_1 = require("./tool-intercept.js");
|
|
79
80
|
const tool_stub_js_1 = require("./tool-stub.js");
|
|
80
81
|
function writeFiles(cwd, files) {
|
|
@@ -1165,6 +1166,9 @@ function evalArmsInputs(spec, cfg) {
|
|
|
1165
1166
|
};
|
|
1166
1167
|
}
|
|
1167
1168
|
async function runEvalWith(spec, runner) {
|
|
1169
|
+
// Tell the CLI runner this script exercised the harness, so a file that runs
|
|
1170
|
+
// NOTHING can be told apart from one that ran and passed. See check-count.ts.
|
|
1171
|
+
(0, check_count_js_1.recordCheck)();
|
|
1168
1172
|
const trials = spec.trials ?? 5;
|
|
1169
1173
|
const spacing = (spec.spacingSec ?? 4) * 1000;
|
|
1170
1174
|
const concurrency = spec.concurrency ?? 1;
|
|
@@ -1637,6 +1641,8 @@ function assertTriggerDiversity(spec) {
|
|
|
1637
1641
|
}
|
|
1638
1642
|
}
|
|
1639
1643
|
async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError, harness = "claude-code") {
|
|
1644
|
+
// Tell the CLI runner this script exercised the harness (see check-count.ts).
|
|
1645
|
+
(0, check_count_js_1.recordCheck)();
|
|
1640
1646
|
// Deterministic gate FIRST — before spending a token (or packaging a skillsDir).
|
|
1641
1647
|
assertTriggerDiversity(spec);
|
|
1642
1648
|
// Model floor (default Sonnet): trigger-rate under-measures selection on a
|
package/dist/harness-assert.js
CHANGED
|
@@ -64,6 +64,7 @@ exports.assertTriggerRate = assertTriggerRate;
|
|
|
64
64
|
const harness_test_js_1 = require("./harness-test.js");
|
|
65
65
|
const hook_program_js_1 = require("./core/hook-program.js");
|
|
66
66
|
const check_js_1 = require("./check.js");
|
|
67
|
+
const check_count_js_1 = require("./check-count.js");
|
|
67
68
|
const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
|
|
68
69
|
const stats_js_1 = require("./stats.js");
|
|
69
70
|
const eval_baseline_js_1 = require("./eval-baseline.js");
|
|
@@ -142,6 +143,15 @@ function assertHookAllowed(r) {
|
|
|
142
143
|
fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* `runHookProgram`, counted. An in-process hook decision is an observation the
|
|
148
|
+
* CLI runner can see — without it, a `*.harness.*` file that only tests compiled
|
|
149
|
+
* hooks would look like it did nothing at all. See check-count.ts.
|
|
150
|
+
*/
|
|
151
|
+
function runCountedHookProgram(hook, event) {
|
|
152
|
+
(0, check_count_js_1.recordCheck)();
|
|
153
|
+
return (0, hook_program_js_1.runHookProgram)(hook, event);
|
|
154
|
+
}
|
|
145
155
|
/** Render a {@link HookProgramOutcome} for an assertion message. */
|
|
146
156
|
function describeOutcome(o) {
|
|
147
157
|
if (o.kind === "decision")
|
|
@@ -157,14 +167,14 @@ function describeOutcome(o) {
|
|
|
157
167
|
* check, use {@link assertHookBlocked} over `runHook`.)
|
|
158
168
|
*/
|
|
159
169
|
function assertHookDenies(hook, event) {
|
|
160
|
-
const o = (
|
|
170
|
+
const o = runCountedHookProgram(hook, event);
|
|
161
171
|
if (o.kind !== "decision" || o.decision.kind !== "deny") {
|
|
162
172
|
fail(`expected the hook to deny, got ${describeOutcome(o)}`);
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
/** Assert a COMPILED hook allows an event (in-process). The twin of {@link assertHookDenies}. */
|
|
166
176
|
function assertHookAllows(hook, event) {
|
|
167
|
-
const o = (
|
|
177
|
+
const o = runCountedHookProgram(hook, event);
|
|
168
178
|
if (o.kind !== "decision" || o.decision.kind !== "allow") {
|
|
169
179
|
fail(`expected the hook to allow, got ${describeOutcome(o)}`);
|
|
170
180
|
}
|
|
@@ -181,7 +191,7 @@ function assertHookAllows(hook, event) {
|
|
|
181
191
|
* stdout-vs-stderr never enters into it.
|
|
182
192
|
*/
|
|
183
193
|
function assertHookNotices(hook, event, matcher) {
|
|
184
|
-
const o = (
|
|
194
|
+
const o = runCountedHookProgram(hook, event);
|
|
185
195
|
if (o.kind !== "reaction" || o.reaction.kind !== "notice") {
|
|
186
196
|
fail(`expected the hook to notice, got ${describeOutcome(o)}`);
|
|
187
197
|
}
|
|
@@ -204,7 +214,7 @@ function assertHookNotices(hook, event, matcher) {
|
|
|
204
214
|
* A `run(…)` reaction is not silent for this purpose: the hook still reacted.
|
|
205
215
|
*/
|
|
206
216
|
function assertHookSilent(hook, event) {
|
|
207
|
-
const o = (
|
|
217
|
+
const o = runCountedHookProgram(hook, event);
|
|
208
218
|
if (o.kind !== "reaction") {
|
|
209
219
|
fail(`expected a react hook, got ${describeOutcome(o)}`);
|
|
210
220
|
}
|
package/dist/harness-test.js
CHANGED
|
@@ -46,6 +46,7 @@ const node_fs_1 = require("node:fs");
|
|
|
46
46
|
const node_os_1 = require("node:os");
|
|
47
47
|
const node_path_1 = require("node:path");
|
|
48
48
|
const adapter_conformance_js_1 = require("./adapter-conformance.js");
|
|
49
|
+
const check_count_js_1 = require("./check-count.js");
|
|
49
50
|
const runtime_js_1 = require("./adapters/claude-code/runtime.js");
|
|
50
51
|
const mock_model_js_1 = require("./mock-model.js");
|
|
51
52
|
const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
|
|
@@ -410,6 +411,9 @@ function makeResult(cwd, out, parsed, turns, modelRequests) {
|
|
|
410
411
|
* only — requesting confinement for another harness throws.
|
|
411
412
|
*/
|
|
412
413
|
async function runHarnessTest(spec, opts = {}) {
|
|
414
|
+
// Tell the CLI runner this script exercised the harness, so a file that runs
|
|
415
|
+
// NOTHING can be told apart from one that ran and passed. See check-count.ts.
|
|
416
|
+
(0, check_count_js_1.recordCheck)();
|
|
413
417
|
const adapter = opts.adapter;
|
|
414
418
|
// Default (no adapter): the unchanged Claude Code driver — keeps the
|
|
415
419
|
// sandbox/confined path and behaviour byte-for-byte identical.
|
package/dist/run-script.js
CHANGED
|
@@ -35,6 +35,7 @@ const node_os_1 = require("node:os");
|
|
|
35
35
|
const node_path_1 = require("node:path");
|
|
36
36
|
const egress_js_1 = require("./egress.js");
|
|
37
37
|
const sandbox_js_1 = require("./sandbox.js");
|
|
38
|
+
const check_count_js_1 = require("./check-count.js");
|
|
38
39
|
/**
|
|
39
40
|
* The run orchestration with injectable spawn seams: pick direct vs. confined
|
|
40
41
|
* via the safe-by-default policy (`decideSandbox`), then assemble the result.
|
|
@@ -42,6 +43,11 @@ const sandbox_js_1 = require("./sandbox.js");
|
|
|
42
43
|
* with fake spawners — no real bwrap.
|
|
43
44
|
*/
|
|
44
45
|
function runScriptWith(command, stdin, opts, deps) {
|
|
46
|
+
// Tell the CLI runner this script exercised the harness, so a `*.harness.*`
|
|
47
|
+
// file that runs NOTHING can be told apart from one that ran and passed. Here,
|
|
48
|
+
// at the primitive, so `runHook` and a bare `runScript` both count. See
|
|
49
|
+
// check-count.ts.
|
|
50
|
+
(0, check_count_js_1.recordCheck)();
|
|
45
51
|
// Allowlisted egress is its own confined path (bwrap netns + slirp4netns +
|
|
46
52
|
// nft); it can't run unconfined, so it refuses outright when the tooling is
|
|
47
53
|
// absent rather than falling back to a direct run that ignores the allowlist.
|
package/dist/scan-core.js
CHANGED
|
@@ -69,6 +69,44 @@ function frontmatter(md) {
|
|
|
69
69
|
color: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "color"),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether this unit's declared TOOL CONTRACT is unreadable — the frontmatter
|
|
74
|
+
* block exists but is not valid YAML.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 WHY SCORING MUST NOT USE THE SALVAGE. The shared reader is deliberately
|
|
77
|
+
* lenient: on a block js-yaml rejects it falls back to a regex salvage, so the
|
|
78
|
+
* live PreToolUse rail still has *something* to enforce and the other fields
|
|
79
|
+
* keep working. That is right for a rail and wrong for a SCORE. Measured
|
|
80
|
+
* 2026-08-08: `readFrontmatter(bad)` returns `{data: null, malformed: true}` —
|
|
81
|
+
* the tool KNOWS the block is broken — while `frontmatterList(…, "allowed-tools")`
|
|
82
|
+
* on the same block returns `["Read","Bash"]`, the narrow contract the author
|
|
83
|
+
* MEANT. Strict js-yaml on it throws `bad indentation of a mapping entry`. So a
|
|
84
|
+
* unit whose contract a strict loader rejects was graded as though it had
|
|
85
|
+
* declared exactly that narrow contract: the Safety ring read BETTER than the
|
|
86
|
+
* truth, on the optimistic branch, in the tool whose own thesis is that the
|
|
87
|
+
* presence of a declaration is not the enforcement of it.
|
|
88
|
+
*
|
|
89
|
+
* The trifecta detector is therefore told the list is a SALVAGE, and reads it as
|
|
90
|
+
* one: it can only make the verdict worse (a salvaged all-three still convicts),
|
|
91
|
+
* and anything short of that falls back to what a strict loader really yields —
|
|
92
|
+
* no contract, i.e. inherits-all. The finding says which happened, so the author
|
|
93
|
+
* can tell a dropped grade from a real capability. Strictly one-directional, the
|
|
94
|
+
* same shape as the inherits-all monotonicity fix (#119). `frontmatter-valid`
|
|
95
|
+
* reports the broken block itself; this is the half that stops the SCORE
|
|
96
|
+
* disagreeing with it.
|
|
97
|
+
*
|
|
98
|
+
* DELIBERATELY NOT WIDER. The typo / never-available / MCP-server /
|
|
99
|
+
* disallowed-tools cross-references keep using the salvage: they are diagnostics,
|
|
100
|
+
* and suppressing them on a malformed file DELETES findings, which moves the
|
|
101
|
+
* grade the optimistic way — the direction this whole fix exists to close. A real
|
|
102
|
+
* vendored plugin in `test/dogfood` proves the point: `madappgang-frontend`'s
|
|
103
|
+
* `tester.md` has both a malformed description and an explicit all-three-legs
|
|
104
|
+
* tool list, and its `AskUserQuestion` never-available finding is true whether or
|
|
105
|
+
* not the block parses.
|
|
106
|
+
*/
|
|
107
|
+
function contractIsUnreadable(md) {
|
|
108
|
+
return (0, frontmatter_read_js_1.readFrontmatter)(md).malformed;
|
|
109
|
+
}
|
|
72
110
|
function escapeRe(s) {
|
|
73
111
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
74
112
|
}
|
|
@@ -257,11 +295,17 @@ function scanSkills(files, cls, ctx) {
|
|
|
257
295
|
// invocable skill can be hijacked by attacker content, so a user-invoked one is
|
|
258
296
|
// excluded. A skill with no `allowed-tools` line inherits all → advisory.
|
|
259
297
|
const skillTools = (0, agent_tools_js_1.parseAgentToolList)(md, "allowed-tools");
|
|
298
|
+
// Whether that list came out of a block a strict loader REJECTS — in which
|
|
299
|
+
// case it is a salvage, and the trifecta detector must read it as one. See
|
|
300
|
+
// `contractIsUnreadable`.
|
|
301
|
+
const contractUnreadable = contractIsUnreadable(md);
|
|
260
302
|
// No `allowed-tools:` line (null) → inherits all → wildcard sentinel; an
|
|
261
303
|
// EXPLICIT empty `[]` means zero tools → no trifecta (don't collapse them).
|
|
262
304
|
const trifecta = userInvoked
|
|
263
305
|
? null
|
|
264
|
-
: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect
|
|
306
|
+
: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect, {
|
|
307
|
+
contractUnreadable,
|
|
308
|
+
});
|
|
265
309
|
out.push({
|
|
266
310
|
name: fm.name ?? skillName(path),
|
|
267
311
|
// Report the real on-disk path, not the synthetic materialize key (E1).
|
|
@@ -323,6 +367,14 @@ ctx) {
|
|
|
323
367
|
if (!cls.isAgent(path))
|
|
324
368
|
continue;
|
|
325
369
|
const tools = (0, agent_tools_js_1.parseAgentTools)(md);
|
|
370
|
+
// Whether that list came out of a block a strict loader REJECTS — see
|
|
371
|
+
// `contractIsUnreadable`. Scoped to the trifecta (the Safety ring) on
|
|
372
|
+
// purpose: the typo / MCP / disallowed-tools cross-references below are
|
|
373
|
+
// DIAGNOSTICS, and dropping them on a malformed file would delete real
|
|
374
|
+
// findings — moving the grade in the optimistic direction this fix exists to
|
|
375
|
+
// stop. A salvage is too weak to earn a unit a clean bill of health; it is
|
|
376
|
+
// plenty strong enough to convict.
|
|
377
|
+
const contractUnreadable = contractIsUnreadable(md);
|
|
326
378
|
// An inherits-all agent (no `tools:` line) grants access to every tool
|
|
327
379
|
// including every side-effecting one — pass the wildcard sentinel so
|
|
328
380
|
// effectSurface correctly classifies it as `"unrestricted"`.
|
|
@@ -359,7 +411,9 @@ ctx) {
|
|
|
359
411
|
// inherits-all agent (no `tools:` line → tools === null) is the advisory
|
|
360
412
|
// case — pass the wildcard sentinel so it's distinguished from an EXPLICIT
|
|
361
413
|
// empty `tools: []` (zero tools → no trifecta). One detector, no drift.
|
|
362
|
-
trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect
|
|
414
|
+
trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect, {
|
|
415
|
+
contractUnreadable,
|
|
416
|
+
}),
|
|
363
417
|
});
|
|
364
418
|
}
|
|
365
419
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
package/dist/testing.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* boundary forbids importing `src/adapters/*` from here. See
|
|
11
11
|
* `research/adapter-api-design.md`.
|
|
12
12
|
*/
|
|
13
|
+
export { recordCheck } from "./check-count.js";
|
|
13
14
|
export { runScript } from "./run-script.js";
|
|
14
15
|
export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
|
|
15
16
|
export { runHook, propertyHook } from "./run-hook.js";
|
package/dist/testing.js
CHANGED
|
@@ -30,7 +30,15 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
30
30
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
31
31
|
};
|
|
32
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
-
exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.propertyHook = exports.runHook = exports.runScript = void 0;
|
|
33
|
+
exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.propertyHook = exports.runHook = exports.runScript = exports.recordCheck = void 0;
|
|
34
|
+
// --- reporting: how much did this script actually do? ---
|
|
35
|
+
// `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
|
|
36
|
+
// prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
|
|
37
|
+
// exporting an object of tests nobody calls). The tiers below count themselves;
|
|
38
|
+
// call `recordCheck()` yourself when you assert some OTHER way — `node:assert`,
|
|
39
|
+
// vitest's `expect` — so those are visible to the runner too. See check-count.ts.
|
|
40
|
+
var check_count_js_1 = require("./check-count.js");
|
|
41
|
+
Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
|
|
34
42
|
// --- unit tier: runScript (the primitive) + runHook (it, plus a decision) ---
|
|
35
43
|
// `runScript` runs any program and reports what it DID (exit, both streams,
|
|
36
44
|
// writes, egress). `runHook` is that plus the hook protocol: event to stdin,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "15.0.
|
|
3
|
+
"version": "15.0.1",
|
|
4
4
|
"description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|