vigiles 14.12.1 → 14.13.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/dist/audit-score.d.ts +7 -4
- package/dist/audit-score.js +31 -28
- package/dist/cli.js +15 -31
- package/dist/core/bash-effects.d.ts +37 -0
- package/dist/core/bash-effects.js +47 -3
- package/dist/core/hook-program.d.ts +31 -1
- package/dist/core/hook-program.js +78 -0
- package/dist/harness-assert.d.ts +21 -0
- package/dist/harness-assert.js +45 -0
- package/dist/hook-install.d.ts +16 -0
- package/dist/hook-install.js +41 -6
- package/dist/hook.d.ts +1 -1
- package/dist/load-hook.d.ts +24 -0
- package/dist/load-hook.js +63 -0
- package/dist/scan.js +6 -2
- package/dist/score-core.d.ts +53 -0
- package/dist/score-core.js +66 -13
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +9 -1
- package/dist/unit.d.ts +1 -0
- package/dist/unit.js +6 -1
- package/package.json +1 -1
package/dist/audit-score.d.ts
CHANGED
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
* (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
|
|
15
15
|
* capability legs is a prompt-injection exfil path detectable from the tool-SET
|
|
16
16
|
* alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
|
|
17
|
-
* (explicit all-three) finding
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* (explicit all-three) finding AND an `"advisory"` (inherits-all) one are BOTH
|
|
18
|
+
* graded — an inherited contract holds the three legs implicitly and every other
|
|
19
|
+
* capability besides, so grading only the explicit case made hardening LOWER the
|
|
20
|
+
* score. The cost is capped against the SHARE of the surface exposed
|
|
21
|
+
* (`W_TRIFECTA=10` per unit, `W_TRIFECTA_MAX=30` total — a DING, not a fail: it
|
|
22
|
+
* dents the grade without a catastrophic F for a pattern official plugins ship by
|
|
23
|
+
* design). NB the EXECUTING
|
|
21
24
|
* "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
|
|
22
25
|
* running arbitrary hooks safely needs cross-platform confinement that isn't
|
|
23
26
|
* shipped yet, so the battery lives in the `vigiles/testing` API via
|
package/dist/audit-score.js
CHANGED
|
@@ -18,10 +18,13 @@ exports.formatAuditScore = formatAuditScore;
|
|
|
18
18
|
* (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
|
|
19
19
|
* capability legs is a prompt-injection exfil path detectable from the tool-SET
|
|
20
20
|
* alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
|
|
21
|
-
* (explicit all-three) finding
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* (explicit all-three) finding AND an `"advisory"` (inherits-all) one are BOTH
|
|
22
|
+
* graded — an inherited contract holds the three legs implicitly and every other
|
|
23
|
+
* capability besides, so grading only the explicit case made hardening LOWER the
|
|
24
|
+
* score. The cost is capped against the SHARE of the surface exposed
|
|
25
|
+
* (`W_TRIFECTA=10` per unit, `W_TRIFECTA_MAX=30` total — a DING, not a fail: it
|
|
26
|
+
* dents the grade without a catastrophic F for a pattern official plugins ship by
|
|
27
|
+
* design). NB the EXECUTING
|
|
25
28
|
* "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
|
|
26
29
|
* running arbitrary hooks safely needs cross-platform confinement that isn't
|
|
27
30
|
* shipped yet, so the battery lives in the `vigiles/testing` API via
|
|
@@ -183,12 +186,14 @@ function structure(r) {
|
|
|
183
186
|
}
|
|
184
187
|
/**
|
|
185
188
|
* SAFETY — fed by the STATIC lethal-trifecta check (`report.trifectaFindings`), a
|
|
186
|
-
* GRADED ring.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
189
|
+
* GRADED ring. EVERY unit holding all three capability legs counts, whether the
|
|
190
|
+
* contract NAMED them (`"hard"`) or INHERITED them (`"advisory"` — no `tools:` /
|
|
191
|
+
* `allowed-tools:` line, so it holds all three legs implicitly AND every other
|
|
192
|
+
* capability besides). Grading only the explicit case made the ring non-monotone:
|
|
193
|
+
* declaring a tool contract — a genuine risk REDUCTION — could only ever lower the
|
|
194
|
+
* score. The penalty is the shared {@link trifectaExposure} (capped against the
|
|
195
|
+
* share of the surface exposed), the SAME number `reportDeductions` sums into the
|
|
196
|
+
* overall, so the ring and the headline agree.
|
|
192
197
|
*
|
|
193
198
|
* Scores `null` (n/a, excluded from the overall) when there's NO tool-bearing
|
|
194
199
|
* surface to assess at all — no subagents AND no model-invocable skills. A
|
|
@@ -197,9 +202,8 @@ function structure(r) {
|
|
|
197
202
|
* the ring is a clean 100.
|
|
198
203
|
*/
|
|
199
204
|
function safety(r) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
if (assessable === 0) {
|
|
205
|
+
const exposure = (0, score_core_js_1.trifectaExposure)(r);
|
|
206
|
+
if (exposure.assessable === 0) {
|
|
203
207
|
return {
|
|
204
208
|
key: "Safety",
|
|
205
209
|
score: null,
|
|
@@ -207,24 +211,23 @@ function safety(r) {
|
|
|
207
211
|
findings: ["no tool-bearing surface to assess"],
|
|
208
212
|
};
|
|
209
213
|
}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
{
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.map((f) => `${f.name} inherits all tools — the "lethal trifecta" (reads data, reaches the web, runs commands), so a prompt injection could exfiltrate secrets (advisory)`);
|
|
214
|
+
const findings = [];
|
|
215
|
+
if (exposure.exposed > 0) {
|
|
216
|
+
findings.push(`${String(exposure.exposed)} of ${String(exposure.assessable)} ` +
|
|
217
|
+
pluralizeLabel(exposure.exposed, score_core_js_1.TRIFECTA_LABEL));
|
|
218
|
+
}
|
|
219
|
+
// NAME the inherited ones: they're both the worst (every capability, not just
|
|
220
|
+
// the three legs) and the cheapest to fix — declare a contract that drops a leg.
|
|
221
|
+
for (const f of r.trifectaFindings) {
|
|
222
|
+
if (f.finding.severity !== "advisory")
|
|
223
|
+
continue;
|
|
224
|
+
findings.push(`${f.name} inherits all tools — the "lethal trifecta" (reads data, reaches the web, runs commands) plus every other capability, so a prompt injection could exfiltrate secrets`);
|
|
225
|
+
}
|
|
223
226
|
return {
|
|
224
227
|
key: "Safety",
|
|
225
|
-
score,
|
|
228
|
+
score: Math.max(0, 100 - exposure.penalty),
|
|
226
229
|
weight: 1,
|
|
227
|
-
findings
|
|
230
|
+
findings,
|
|
228
231
|
};
|
|
229
232
|
}
|
|
230
233
|
function tested(r) {
|
package/dist/cli.js
CHANGED
|
@@ -69,6 +69,7 @@ const adopt_js_1 = require("./core/adopt.js");
|
|
|
69
69
|
const coverage_js_1 = require("./core/coverage.js");
|
|
70
70
|
const orphans_js_1 = require("./core/orphans.js");
|
|
71
71
|
const doc_refs_js_1 = require("./core/doc-refs.js");
|
|
72
|
+
const load_hook_js_1 = require("./load-hook.js");
|
|
72
73
|
// ---------------------------------------------------------------------------
|
|
73
74
|
// Constants
|
|
74
75
|
// ---------------------------------------------------------------------------
|
|
@@ -4566,33 +4567,11 @@ function refsHookCommand() {
|
|
|
4566
4567
|
// compile it to a harness block, run it as the hooks-block command.
|
|
4567
4568
|
// ---------------------------------------------------------------------------
|
|
4568
4569
|
/**
|
|
4569
|
-
* Load a compiled-hook program's default export
|
|
4570
|
-
*
|
|
4571
|
-
* loads
|
|
4572
|
-
* actionable error points at authoring it as `.mjs`.
|
|
4570
|
+
* Load a compiled-hook program's default export — the SHARED loader, also the
|
|
4571
|
+
* public `vigiles/testing` `loadHook` a `.harness.mjs` test uses, so a hook that
|
|
4572
|
+
* loads in a test loads identically here (one loader, no drift).
|
|
4573
4573
|
*/
|
|
4574
|
-
|
|
4575
|
-
const abs = (0, node_path_1.resolve)(process.cwd(), file);
|
|
4576
|
-
const { pathToFileURL } = require("node:url");
|
|
4577
|
-
let mod;
|
|
4578
|
-
try {
|
|
4579
|
-
mod = (await import(pathToFileURL(abs).href));
|
|
4580
|
-
}
|
|
4581
|
-
catch (e) {
|
|
4582
|
-
if (/\.(?:m|c)?ts$/.test(file)) {
|
|
4583
|
-
throw new hook_program_js_1.HookCompileError(`Cannot load TypeScript hook "${file}" in this Node runtime. Run under ` +
|
|
4584
|
-
`tsx (npx tsx …) / Node >= 23.6, or author the hook as a .mjs file.`);
|
|
4585
|
-
}
|
|
4586
|
-
throw new hook_program_js_1.HookCompileError(`Cannot load hook "${file}": ${e.message}`);
|
|
4587
|
-
}
|
|
4588
|
-
// Unwrap the ESM/CJS double-default that `export default` can produce.
|
|
4589
|
-
const program = mod.default?.default ?? mod.default;
|
|
4590
|
-
if (!program || typeof program !== "object") {
|
|
4591
|
-
throw new hook_program_js_1.HookCompileError(`${file} has no default-exported hook program ` +
|
|
4592
|
-
`(use \`export default defineHook({…})\`).`);
|
|
4593
|
-
}
|
|
4594
|
-
return program;
|
|
4595
|
-
}
|
|
4574
|
+
const loadHookProgram = load_hook_js_1.loadHook;
|
|
4596
4575
|
/** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
|
|
4597
4576
|
async function loadProvider(file) {
|
|
4598
4577
|
const abs = (0, node_path_1.resolve)(process.cwd(), file);
|
|
@@ -4643,14 +4622,19 @@ function hookStampPath(file) {
|
|
|
4643
4622
|
* outside the sanctioned API does NOT compile (capability = API surface). The
|
|
4644
4623
|
* emitted block routes the live event to `hook-runtime run-program`; a
|
|
4645
4624
|
* tamper-evident stamp sidecar lets the runtime refuse a hand-edited artifact.
|
|
4646
|
-
* The merge is idempotent (keyed by the hook
|
|
4647
|
-
*
|
|
4625
|
+
* The merge is idempotent (keyed by the hook FILE via `normalizeHookRef`, not by
|
|
4626
|
+
* the string the user typed — `x.hook.ts` and `./x.hook.ts` are the same wiring),
|
|
4627
|
+
* so recompiling updates in place and never clobbers the user's own hooks.
|
|
4648
4628
|
*/
|
|
4649
4629
|
async function installHookFile(file, adapter, registeredProviders = []) {
|
|
4650
4630
|
const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
|
|
4651
4631
|
const program = await loadHookProgram(file);
|
|
4632
|
+
// Emit the CANONICAL path so two spellings of the same file produce the same
|
|
4633
|
+
// command — the merge key and the emitted command must agree, or recompiling
|
|
4634
|
+
// appends a duplicate block instead of replacing the existing one.
|
|
4635
|
+
const ref = (0, hook_install_js_1.normalizeHookRef)(file);
|
|
4652
4636
|
const compiled = (0, hook_program_js_1.compileHookProgram)(source, program, {
|
|
4653
|
-
gateCommand: `npx vigiles hook-runtime run-program ${
|
|
4637
|
+
gateCommand: `npx vigiles hook-runtime run-program ${ref}`,
|
|
4654
4638
|
dialect: adapter.dialect,
|
|
4655
4639
|
hookProtocol: adapter.hookProtocol,
|
|
4656
4640
|
settingsFormat: adapter.layout.settingsFormat,
|
|
@@ -4668,8 +4652,8 @@ async function installHookFile(file, adapter, registeredProviders = []) {
|
|
|
4668
4652
|
: JSON.parse((0, node_fs_1.readFileSync)(settingsAbs, "utf-8"))
|
|
4669
4653
|
: {};
|
|
4670
4654
|
const merged = format === "toml"
|
|
4671
|
-
? (0, hook_install_js_1.mergeHooksToml)(existing, compiled.hooks,
|
|
4672
|
-
: (0, hook_install_js_1.mergeHooksJson)(existing, compiled.hooks,
|
|
4655
|
+
? (0, hook_install_js_1.mergeHooksToml)(existing, compiled.hooks, ref)
|
|
4656
|
+
: (0, hook_install_js_1.mergeHooksJson)(existing, compiled.hooks, ref);
|
|
4673
4657
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(settingsAbs), { recursive: true });
|
|
4674
4658
|
(0, node_fs_1.writeFileSync)(settingsAbs, (0, hook_install_js_1.serializeConfig)(merged, format));
|
|
4675
4659
|
// No silent skips: warn loudly only where a hook's OUTPUT genuinely may not
|
|
@@ -50,6 +50,33 @@ export declare function isReadOnlyBash(command: string): boolean;
|
|
|
50
50
|
* native `Bash(git:*)` glob (issue #30519) and a hand-written `grep` both miss.
|
|
51
51
|
*/
|
|
52
52
|
export declare function leafCommands(command: string): string[][];
|
|
53
|
+
/**
|
|
54
|
+
* One redirection attached to a simple command (`cmd > f`, `cmd 2>> log`).
|
|
55
|
+
*
|
|
56
|
+
* The parser used to DROP these: `echo x > out.md` normalized to a leaf whose
|
|
57
|
+
* every field (`head`/`argv`/`args`/`flags`/`assigns`) mentioned only `echo` and
|
|
58
|
+
* `x`, so the file the command actually WROTE appeared nowhere. That made the
|
|
59
|
+
* single most common write shape invisible to any matcher built on the leaf.
|
|
60
|
+
*/
|
|
61
|
+
export interface LeafRedirect {
|
|
62
|
+
/** The operator as written: `>`, `>>`, `>|`, `&>`, `&>>`, `<`, `<<`, `>&`, … */
|
|
63
|
+
readonly op: string;
|
|
64
|
+
/**
|
|
65
|
+
* The redirection target, quote-unwrapped and `$HOME`-canonicalized like every
|
|
66
|
+
* other word. `null` when the target is dynamic (`> "$out"`, `> $(f)`) — present
|
|
67
|
+
* but unresolvable, never silently dropped. For an fd-dup (`2>&1`) this is the
|
|
68
|
+
* fd, not a path; for a heredoc it's the delimiter.
|
|
69
|
+
*/
|
|
70
|
+
readonly target: string | null;
|
|
71
|
+
/** The explicit source fd (`2> f` → 2), or `null` when omitted. */
|
|
72
|
+
readonly fd: number | null;
|
|
73
|
+
/**
|
|
74
|
+
* True iff this redirection CREATES OR MODIFIES the file named by `target` —
|
|
75
|
+
* `>`, `>>`, `>|`, `&>`, `&>>`. False for input (`<`, `<<`, `<<<`) and for fd
|
|
76
|
+
* duplication (`2>&1`), whose "target" is an fd, not a path.
|
|
77
|
+
*/
|
|
78
|
+
readonly writes: boolean;
|
|
79
|
+
}
|
|
53
80
|
/** A single simple command, normalized to its operation form. */
|
|
54
81
|
export interface NormalizedLeaf {
|
|
55
82
|
/** Head normalized to its basename, backslash-stripped: `/bin/rm`→`rm`, `\rm`→`rm`. */
|
|
@@ -80,6 +107,11 @@ export interface NormalizedLeaf {
|
|
|
80
107
|
readonly assigns: ReadonlyMap<string, string | null>;
|
|
81
108
|
/** True iff a command-level assignment for ANY of `names` is present (resolved or not). */
|
|
82
109
|
hasAssign(...names: readonly string[]): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The redirections attached to this leaf, in source order — see
|
|
112
|
+
* {@link LeafRedirect}. Empty for a command with no redirection.
|
|
113
|
+
*/
|
|
114
|
+
readonly redirects: readonly LeafRedirect[];
|
|
83
115
|
}
|
|
84
116
|
/**
|
|
85
117
|
* Extract every simple command as a {@link NormalizedLeaf} — the operation-level
|
|
@@ -90,6 +122,11 @@ export interface NormalizedLeaf {
|
|
|
90
122
|
* compares against the OPERATION rather than the surface tokens, so it is robust
|
|
91
123
|
* to quoting, interpreter path, backslash escaping, flag aliasing, and $HOME/~.
|
|
92
124
|
*
|
|
125
|
+
* Each leaf also carries its REDIRECTIONS ({@link LeafRedirect}), which live on
|
|
126
|
+
* the enclosing `Stmt` rather than the `CallExpr` — walking CallExprs alone
|
|
127
|
+
* dropped them, hiding the file a command writes. Every CallExpr in the mvdan AST
|
|
128
|
+
* IS a `Stmt.Cmd`, so iterating statements finds exactly the same leaves.
|
|
129
|
+
*
|
|
93
130
|
* Purely additive: reuses the same mvdan-sh parse, changes nothing above. Parse
|
|
94
131
|
* failure → []. A leaf with a dynamic head is skipped (can't be normalized).
|
|
95
132
|
*/
|
|
@@ -42,6 +42,27 @@ const sh = _sh;
|
|
|
42
42
|
// 61 = << (Hdoc — input) 63 = <<< (HereStr — input)
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
const WRITE_REDIR_OPS = new Set([54, 55, 60, 64, 65]);
|
|
45
|
+
/**
|
|
46
|
+
* Op code → the operator as WRITTEN, so a normalized leaf can REPORT the
|
|
47
|
+
* redirection it carries instead of a bare number. Pinned by
|
|
48
|
+
* `bash-effects-normalized.test.ts`, which re-derives every entry by parsing the
|
|
49
|
+
* operator itself — an mvdan-sh upgrade that renumbers a token fails there
|
|
50
|
+
* LOUDLY instead of silently misclassifying a write as a read.
|
|
51
|
+
*/
|
|
52
|
+
const REDIR_OP_NAMES = new Map([
|
|
53
|
+
[54, ">"],
|
|
54
|
+
[55, ">>"],
|
|
55
|
+
[56, "<"],
|
|
56
|
+
[57, "<>"],
|
|
57
|
+
[58, "<&"],
|
|
58
|
+
[59, ">&"],
|
|
59
|
+
[60, ">|"],
|
|
60
|
+
[61, "<<"],
|
|
61
|
+
[62, "<<-"],
|
|
62
|
+
[63, "<<<"],
|
|
63
|
+
[64, "&>"],
|
|
64
|
+
[65, "&>>"],
|
|
65
|
+
]);
|
|
45
66
|
// ---------------------------------------------------------------------------
|
|
46
67
|
// Shell-escape heads: commands that dispatch arbitrary code as an argument.
|
|
47
68
|
// We treat ALL of these as undecidable regardless of their flags.
|
|
@@ -671,6 +692,11 @@ function stripWrappers(argv) {
|
|
|
671
692
|
* compares against the OPERATION rather than the surface tokens, so it is robust
|
|
672
693
|
* to quoting, interpreter path, backslash escaping, flag aliasing, and $HOME/~.
|
|
673
694
|
*
|
|
695
|
+
* Each leaf also carries its REDIRECTIONS ({@link LeafRedirect}), which live on
|
|
696
|
+
* the enclosing `Stmt` rather than the `CallExpr` — walking CallExprs alone
|
|
697
|
+
* dropped them, hiding the file a command writes. Every CallExpr in the mvdan AST
|
|
698
|
+
* IS a `Stmt.Cmd`, so iterating statements finds exactly the same leaves.
|
|
699
|
+
*
|
|
674
700
|
* Purely additive: reuses the same mvdan-sh parse, changes nothing above. Parse
|
|
675
701
|
* failure → []. A leaf with a dynamic head is skipped (can't be normalized).
|
|
676
702
|
*/
|
|
@@ -684,13 +710,27 @@ function leafCommandsNormalized(command) {
|
|
|
684
710
|
}
|
|
685
711
|
const out = [];
|
|
686
712
|
sh.syntax.Walk(file, (node) => {
|
|
687
|
-
|
|
713
|
+
if (sh.syntax.NodeType(node) !== "Stmt" || !node.Cmd)
|
|
714
|
+
return true;
|
|
715
|
+
const leaf = normalizeCallExpr(node.Cmd, node.Redirs ?? []);
|
|
688
716
|
if (leaf)
|
|
689
717
|
out.push(leaf);
|
|
690
718
|
return true;
|
|
691
719
|
});
|
|
692
720
|
return out;
|
|
693
721
|
}
|
|
722
|
+
/** Normalize a Stmt's redirections into {@link LeafRedirect}s (source order). */
|
|
723
|
+
function normalizeRedirects(redirs) {
|
|
724
|
+
return redirs.map((r) => {
|
|
725
|
+
const fd = Number(r.N?.Value);
|
|
726
|
+
return {
|
|
727
|
+
op: REDIR_OP_NAMES.get(r.Op) ?? String(r.Op),
|
|
728
|
+
target: r.Word ? normalizeParts(r.Word.Parts) : null,
|
|
729
|
+
fd: Number.isInteger(fd) ? fd : null,
|
|
730
|
+
writes: WRITE_REDIR_OPS.has(r.Op),
|
|
731
|
+
};
|
|
732
|
+
});
|
|
733
|
+
}
|
|
694
734
|
/** Collect a CallExpr's leading `NAME=value` env-assignments into a name→value map. */
|
|
695
735
|
function collectAssigns(node) {
|
|
696
736
|
const assigns = new Map();
|
|
@@ -704,8 +744,11 @@ function collectAssigns(node) {
|
|
|
704
744
|
}
|
|
705
745
|
return assigns;
|
|
706
746
|
}
|
|
707
|
-
/**
|
|
708
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Normalize a single CallExpr node (plus the redirections of the `Stmt` that
|
|
749
|
+
* wraps it) to a {@link NormalizedLeaf}, or null if it isn't one / has a dynamic head.
|
|
750
|
+
*/
|
|
751
|
+
function normalizeCallExpr(node, redirs) {
|
|
709
752
|
if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
|
|
710
753
|
return null;
|
|
711
754
|
const headRaw = normalizeParts(node.Args[0]?.Parts);
|
|
@@ -734,6 +777,7 @@ function normalizeCallExpr(node) {
|
|
|
734
777
|
hasFlag: (...names) => names.some((n) => flags.has(n)),
|
|
735
778
|
assigns,
|
|
736
779
|
hasAssign: (...names) => names.some((n) => assigns.has(n)),
|
|
780
|
+
redirects: normalizeRedirects(redirs),
|
|
737
781
|
};
|
|
738
782
|
}
|
|
739
783
|
//# sourceMappingURL=bash-effects.js.map
|
|
@@ -87,11 +87,41 @@ export interface CommandView {
|
|
|
87
87
|
/** True iff the command is provably side-effecting (bash-effects classifier). */
|
|
88
88
|
isSideEffecting(): boolean;
|
|
89
89
|
/**
|
|
90
|
-
* True iff a leaf command
|
|
90
|
+
* True iff a leaf command MENTIONS a path under one of the prefixes (e.g.
|
|
91
91
|
* `~/.ssh`, `.env`) — the secret-read / sensitive-path matcher. Sees the path
|
|
92
92
|
* however the command is wrapped (`cd x && cat ~/.ssh/id_rsa`).
|
|
93
|
+
*
|
|
94
|
+
* MENTIONS, not writes. `grep -c x notes/S.md` touches `notes` — so does
|
|
95
|
+
* `rm -rf notes`. Pairing this with {@link isSideEffecting}, which classifies
|
|
96
|
+
* the WHOLE command line, does NOT recover the difference: a plain read whose
|
|
97
|
+
* line happens to be side-effecting for an unrelated reason (`grep -c x
|
|
98
|
+
* notes/S.md 2>/dev/null`) matches both and gets blocked. To gate WRITES to a
|
|
99
|
+
* directory, use {@link writesTo}; conflating the two is the trap.
|
|
93
100
|
*/
|
|
94
101
|
touches(prefixes: readonly string[]): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* True iff a leaf command CREATES OR MODIFIES a file under one of the prefixes
|
|
104
|
+
* — the "don't let Bash write here" matcher, and the precise counterpart to
|
|
105
|
+
* {@link touches}. Two sources, both AST-backed:
|
|
106
|
+
*
|
|
107
|
+
* - **Redirection targets** — `cmd > f`, `cmd >> f`, `cmd >| f`, `cmd &> f`.
|
|
108
|
+
* This is the single most common write shape, and it lives on the statement,
|
|
109
|
+
* not in any argv.
|
|
110
|
+
* - **File-writing programs**, at the argv positions that actually name the
|
|
111
|
+
* file written: `sed -i`, `cp`/`mv`/`install` (destination), `tee`, `dd of=`,
|
|
112
|
+
* `truncate`, `shred`.
|
|
113
|
+
*
|
|
114
|
+
* A path merely READ never matches — `cat a/paper.md`, `grep x a/paper.md`,
|
|
115
|
+
* `cp a/paper.md /tmp/x` (the source is read, `/tmp/x` is the write) are all
|
|
116
|
+
* false for `writesTo(["a"])`. Quoting is handled by the parser, so a write
|
|
117
|
+
* QUOTED inside another command does not match: in
|
|
118
|
+
* `echo 'echo y > a/paper.md' > /tmp/note.txt` the only real target is
|
|
119
|
+
* `/tmp/note.txt`.
|
|
120
|
+
*
|
|
121
|
+
* Deletion is a different question and is deliberately NOT reported here —
|
|
122
|
+
* pair with `runs("rm")` if a gate cares about removal too.
|
|
123
|
+
*/
|
|
124
|
+
writesTo(prefixes: readonly string[]): boolean;
|
|
95
125
|
/**
|
|
96
126
|
* True iff the command pipes into a BARE shell interpreter (`curl … | sh`,
|
|
97
127
|
* `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf
|
|
@@ -112,8 +112,85 @@ function tokenUnder(token, prefix) {
|
|
|
112
112
|
function isBareShellLeaf(argv) {
|
|
113
113
|
return (SHELLS.has(argv[0] ?? "") && argv.slice(1).every((a) => a.startsWith("-")));
|
|
114
114
|
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// writesTo — which argv positions of a known file-writing program name the file
|
|
117
|
+
// it WRITES. Deliberately small and high-precision: an unlisted head contributes
|
|
118
|
+
// no write target (the redirection half above already covers `cmd > f`), so this
|
|
119
|
+
// never guesses. Anything that only READS its operands (cat/grep/…) is absent by
|
|
120
|
+
// construction.
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/** Options that consume a SEPARATE following token, per writer head. */
|
|
123
|
+
const WRITER_VALUE_OPTS = {
|
|
124
|
+
sed: new Set(["-e", "--expression", "-f", "--file", "-l", "--line-length"]),
|
|
125
|
+
truncate: new Set(["-s", "--size", "-r", "--reference"]),
|
|
126
|
+
tee: new Set(["-p", "--output-error"]),
|
|
127
|
+
install: new Set(["-m", "--mode", "-o", "--owner", "-g", "--group", "-t"]),
|
|
128
|
+
cp: new Set(["-t", "--target-directory", "-S", "--suffix"]),
|
|
129
|
+
mv: new Set(["-t", "--target-directory", "-S", "--suffix"]),
|
|
130
|
+
shred: new Set(["-n", "--iterations", "-s", "--size"]),
|
|
131
|
+
dd: new Set(),
|
|
132
|
+
};
|
|
133
|
+
/** The non-option operands of a leaf, with each option's separate value skipped. */
|
|
134
|
+
function operandsOf(leaf) {
|
|
135
|
+
const valueOpts = WRITER_VALUE_OPTS[leaf.head] ?? new Set();
|
|
136
|
+
const out = [];
|
|
137
|
+
for (let i = 0; i < leaf.args.length; i++) {
|
|
138
|
+
const a = leaf.args[i];
|
|
139
|
+
if (a === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
if (a === "--") {
|
|
142
|
+
out.push(...leaf.args.slice(i + 1).filter((x) => x !== undefined));
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (a.length > 1 && a.startsWith("-")) {
|
|
146
|
+
if (valueOpts.has(a))
|
|
147
|
+
i++; // this option's value is not an operand
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
out.push(a);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The paths a leaf WRITES, by head. Empty for anything not known to write —
|
|
156
|
+
* including every read-only command, so a read can never be mistaken for a write.
|
|
157
|
+
*/
|
|
158
|
+
function writeTargetsOf(leaf) {
|
|
159
|
+
const operands = operandsOf(leaf);
|
|
160
|
+
switch (leaf.head) {
|
|
161
|
+
case "sed":
|
|
162
|
+
// Only `sed -i` edits in place; otherwise it writes to stdout. The first
|
|
163
|
+
// operand is the SCRIPT unless it was supplied via -e/-f.
|
|
164
|
+
if (!leaf.hasFlag("i", "in-place"))
|
|
165
|
+
return [];
|
|
166
|
+
return leaf.hasFlag("e", "expression", "f", "file")
|
|
167
|
+
? operands
|
|
168
|
+
: operands.slice(1);
|
|
169
|
+
case "cp":
|
|
170
|
+
case "mv":
|
|
171
|
+
case "install":
|
|
172
|
+
// The LAST operand is the destination; every earlier one is read.
|
|
173
|
+
return operands.length >= 2 ? operands.slice(-1) : [];
|
|
174
|
+
case "tee":
|
|
175
|
+
case "truncate":
|
|
176
|
+
case "shred":
|
|
177
|
+
return operands;
|
|
178
|
+
case "dd":
|
|
179
|
+
// `dd if=src of=dest` — only the output file is written.
|
|
180
|
+
return leaf.args.flatMap((a) => a.startsWith("of=") ? [a.slice(3)] : []);
|
|
181
|
+
default:
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
115
185
|
function commandView(raw) {
|
|
116
186
|
const leaves = (0, bash_effects_js_1.leafCommands)(raw);
|
|
187
|
+
// The operation-normalized leaves carry the redirections (and quote-unwrapped,
|
|
188
|
+
// wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
|
|
189
|
+
const normalized = (0, bash_effects_js_1.leafCommandsNormalized)(raw);
|
|
190
|
+
const writeTargets = normalized.flatMap((leaf) => [
|
|
191
|
+
...leaf.redirects.flatMap((r) => r.writes && r.target !== null ? [r.target] : []),
|
|
192
|
+
...writeTargetsOf(leaf),
|
|
193
|
+
]);
|
|
117
194
|
return {
|
|
118
195
|
raw,
|
|
119
196
|
runs(program, opts) {
|
|
@@ -122,6 +199,7 @@ function commandView(raw) {
|
|
|
122
199
|
},
|
|
123
200
|
isSideEffecting: () => (0, bash_effects_js_1.classifyBashCommand)(raw) === "side-effecting",
|
|
124
201
|
touches: (prefixes) => leaves.some((argv) => argv.slice(1).some((tok) => prefixes.some((p) => tokenUnder(tok, p)))),
|
|
202
|
+
writesTo: (prefixes) => writeTargets.some((t) => prefixes.some((p) => tokenUnder(t, p))),
|
|
125
203
|
pipesToShell: () => leaves.some(isBareShellLeaf),
|
|
126
204
|
};
|
|
127
205
|
}
|
package/dist/harness-assert.d.ts
CHANGED
|
@@ -60,6 +60,27 @@ export declare function assertHookAllowed(r: HookRunResult): void;
|
|
|
60
60
|
export declare function assertHookDenies(hook: AnyHook, event: RawHookEvent): void;
|
|
61
61
|
/** Assert a COMPILED hook allows an event (in-process). The twin of {@link assertHookDenies}. */
|
|
62
62
|
export declare function assertHookAllows(hook: AnyHook, event: RawHookEvent): void;
|
|
63
|
+
/**
|
|
64
|
+
* Assert a COMPILED **react** hook emits a `notice(…)` for an event, optionally
|
|
65
|
+
* matching its message — the react-tier twin of {@link assertHookDenies}.
|
|
66
|
+
*
|
|
67
|
+
* A react hook can't block, so the gate assertions don't apply to it, and there
|
|
68
|
+
* was no assertion that did. That left `notice()` a live trap: its message goes
|
|
69
|
+
* to **stderr**, so a probe built on `execFileSync` (which returns stdout only)
|
|
70
|
+
* sees nothing and reports a perfectly healthy react hook as DEAD — measured
|
|
71
|
+
* against three real hooks on 2026-08-03. Reading the reaction in-process means
|
|
72
|
+
* stdout-vs-stderr never enters into it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function assertHookNotices(hook: AnyHook, event: RawHookEvent, matcher?: string | RegExp): void;
|
|
75
|
+
/**
|
|
76
|
+
* Assert a COMPILED **react** hook stays silent for an event — it returns
|
|
77
|
+
* `nothing()` (a `"none"` reaction). The twin of {@link assertHookNotices}: together they pin both
|
|
78
|
+
* halves of a react hook's behaviour — it fires when it should, and (the half
|
|
79
|
+
* that actually regresses) it does NOT fire when it shouldn't.
|
|
80
|
+
*
|
|
81
|
+
* A `run(…)` reaction is not silent for this purpose: the hook still reacted.
|
|
82
|
+
*/
|
|
83
|
+
export declare function assertHookSilent(hook: AnyHook, event: RawHookEvent): void;
|
|
63
84
|
/** Anything carrying recorded egress attempts (a runHook recordEgress result). */
|
|
64
85
|
interface HasEgress {
|
|
65
86
|
readonly egress: readonly EgressAttempt[];
|
package/dist/harness-assert.js
CHANGED
|
@@ -10,6 +10,8 @@ exports.assertHookBlocked = assertHookBlocked;
|
|
|
10
10
|
exports.assertHookAllowed = assertHookAllowed;
|
|
11
11
|
exports.assertHookDenies = assertHookDenies;
|
|
12
12
|
exports.assertHookAllows = assertHookAllows;
|
|
13
|
+
exports.assertHookNotices = assertHookNotices;
|
|
14
|
+
exports.assertHookSilent = assertHookSilent;
|
|
13
15
|
exports.egressHosts = egressHosts;
|
|
14
16
|
exports.assertNoEgress = assertNoEgress;
|
|
15
17
|
exports.assertEgressOnly = assertEgressOnly;
|
|
@@ -167,6 +169,49 @@ function assertHookAllows(hook, event) {
|
|
|
167
169
|
fail(`expected the hook to allow, got ${describeOutcome(o)}`);
|
|
168
170
|
}
|
|
169
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Assert a COMPILED **react** hook emits a `notice(…)` for an event, optionally
|
|
174
|
+
* matching its message — the react-tier twin of {@link assertHookDenies}.
|
|
175
|
+
*
|
|
176
|
+
* A react hook can't block, so the gate assertions don't apply to it, and there
|
|
177
|
+
* was no assertion that did. That left `notice()` a live trap: its message goes
|
|
178
|
+
* to **stderr**, so a probe built on `execFileSync` (which returns stdout only)
|
|
179
|
+
* sees nothing and reports a perfectly healthy react hook as DEAD — measured
|
|
180
|
+
* against three real hooks on 2026-08-03. Reading the reaction in-process means
|
|
181
|
+
* stdout-vs-stderr never enters into it.
|
|
182
|
+
*/
|
|
183
|
+
function assertHookNotices(hook, event, matcher) {
|
|
184
|
+
const o = (0, hook_program_js_1.runHookProgram)(hook, event);
|
|
185
|
+
if (o.kind !== "reaction" || o.reaction.kind !== "notice") {
|
|
186
|
+
fail(`expected the hook to notice, got ${describeOutcome(o)}`);
|
|
187
|
+
}
|
|
188
|
+
if (matcher === undefined)
|
|
189
|
+
return;
|
|
190
|
+
const { message } = o.reaction;
|
|
191
|
+
const matched = typeof matcher === "string"
|
|
192
|
+
? message.includes(matcher)
|
|
193
|
+
: matcher.test(message);
|
|
194
|
+
if (!matched) {
|
|
195
|
+
fail(`expected the notice to match ${String(matcher)}, got ${JSON.stringify(message)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Assert a COMPILED **react** hook stays silent for an event — it returns
|
|
200
|
+
* `nothing()` (a `"none"` reaction). The twin of {@link assertHookNotices}: together they pin both
|
|
201
|
+
* halves of a react hook's behaviour — it fires when it should, and (the half
|
|
202
|
+
* that actually regresses) it does NOT fire when it shouldn't.
|
|
203
|
+
*
|
|
204
|
+
* A `run(…)` reaction is not silent for this purpose: the hook still reacted.
|
|
205
|
+
*/
|
|
206
|
+
function assertHookSilent(hook, event) {
|
|
207
|
+
const o = (0, hook_program_js_1.runHookProgram)(hook, event);
|
|
208
|
+
if (o.kind !== "reaction") {
|
|
209
|
+
fail(`expected a react hook, got ${describeOutcome(o)}`);
|
|
210
|
+
}
|
|
211
|
+
if (o.reaction.kind !== "none") {
|
|
212
|
+
fail(`expected the hook to stay silent, got ${describeOutcome(o)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
170
215
|
const hostPort = (e) => `${e.host}:${String(e.port)}`;
|
|
171
216
|
/** The `host:port` strings a run attempted, e.g. `["registry.npmjs.org:443"]`. */
|
|
172
217
|
function egressHosts(r) {
|
package/dist/hook-install.d.ts
CHANGED
|
@@ -20,6 +20,22 @@ interface SettingsJson {
|
|
|
20
20
|
hooks?: Record<string, HookEntry[]>;
|
|
21
21
|
[k: string]: unknown;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The CANONICAL form of a hook-source reference — how the path is written into
|
|
25
|
+
* the emitted runtime command AND how an existing entry is recognized as "this
|
|
26
|
+
* hook", so the merge is keyed by the FILE, not by the string the user typed.
|
|
27
|
+
*
|
|
28
|
+
* Without this, `vigiles compile x.hook.ts` and `vigiles compile ./x.hook.ts`
|
|
29
|
+
* wired the SAME file twice: the second run's `hookPath` (`./x.hook.ts`) wasn't a
|
|
30
|
+
* substring of the first run's command (`… run-program x.hook.ts`), so nothing
|
|
31
|
+
* was replaced and a second `{matcher, hooks:[…]}` block was appended. A few
|
|
32
|
+
* iterations of an edit-compile loop left duplicate wirings that all fire.
|
|
33
|
+
*
|
|
34
|
+
* Canonical = POSIX separators, no `./` prefix, resolved against the cwd and made
|
|
35
|
+
* relative when it lives under it (an absolute path outside the repo is kept
|
|
36
|
+
* absolute — still stable, just not relative to anything).
|
|
37
|
+
*/
|
|
38
|
+
export declare function normalizeHookRef(hookPath: string, cwd?: string): string;
|
|
23
39
|
/**
|
|
24
40
|
* Idempotently merge a compiled hook's block into an existing `settings.json`
|
|
25
41
|
* object. Entries managed by THIS hook file (the runtime command references
|
package/dist/hook-install.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.PROVIDERS_DIR = exports.HOOKS_DIR = void 0;
|
|
4
4
|
exports.discoverHookFiles = discoverHookFiles;
|
|
5
5
|
exports.discoverProviderFiles = discoverProviderFiles;
|
|
6
|
+
exports.normalizeHookRef = normalizeHookRef;
|
|
6
7
|
exports.mergeHooksJson = mergeHooksJson;
|
|
7
8
|
exports.mergeHooksToml = mergeHooksToml;
|
|
8
9
|
exports.serializeConfig = serializeConfig;
|
|
@@ -18,9 +19,11 @@ exports.serializeConfig = serializeConfig;
|
|
|
18
19
|
* result into the active harness's native config (`.claude/settings.json` JSON
|
|
19
20
|
* / `config.toml` TOML) — so the harness is actually wired, not handed a
|
|
20
21
|
* paste-this block. The merge is idempotent: an entry is keyed by the runtime
|
|
21
|
-
* command's hook
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* command's hook path, CANONICALIZED ({@link normalizeHookRef}) so it identifies
|
|
23
|
+
* the FILE rather than the string the user typed — recompiling updates in place
|
|
24
|
+
* and never duplicates, while a user's own hand-written hooks are preserved
|
|
25
|
+
* untouched. One source dir also means basenames are unique, so the stamp can key
|
|
26
|
+
* on the basename safely.
|
|
24
27
|
*/
|
|
25
28
|
const node_fs_1 = require("node:fs");
|
|
26
29
|
const node_path_1 = require("node:path");
|
|
@@ -49,9 +52,40 @@ function discoverHookFiles(cwd) {
|
|
|
49
52
|
function discoverProviderFiles(cwd) {
|
|
50
53
|
return discoverSources(cwd, exports.PROVIDERS_DIR);
|
|
51
54
|
}
|
|
52
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The CANONICAL form of a hook-source reference — how the path is written into
|
|
57
|
+
* the emitted runtime command AND how an existing entry is recognized as "this
|
|
58
|
+
* hook", so the merge is keyed by the FILE, not by the string the user typed.
|
|
59
|
+
*
|
|
60
|
+
* Without this, `vigiles compile x.hook.ts` and `vigiles compile ./x.hook.ts`
|
|
61
|
+
* wired the SAME file twice: the second run's `hookPath` (`./x.hook.ts`) wasn't a
|
|
62
|
+
* substring of the first run's command (`… run-program x.hook.ts`), so nothing
|
|
63
|
+
* was replaced and a second `{matcher, hooks:[…]}` block was appended. A few
|
|
64
|
+
* iterations of an edit-compile loop left duplicate wirings that all fire.
|
|
65
|
+
*
|
|
66
|
+
* Canonical = POSIX separators, no `./` prefix, resolved against the cwd and made
|
|
67
|
+
* relative when it lives under it (an absolute path outside the repo is kept
|
|
68
|
+
* absolute — still stable, just not relative to anything).
|
|
69
|
+
*/
|
|
70
|
+
function normalizeHookRef(hookPath, cwd = process.cwd()) {
|
|
71
|
+
const abs = (0, node_path_1.resolve)(cwd, hookPath);
|
|
72
|
+
const rel = (0, node_path_1.relative)(cwd, abs);
|
|
73
|
+
const chosen = rel === "" || rel.startsWith("..") ? abs : rel;
|
|
74
|
+
return chosen.split(node_path_1.sep).join("/");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True when an entry's command routes through the runtime for `hookPath`.
|
|
78
|
+
*
|
|
79
|
+
* Compares CANONICALIZED path tokens rather than testing for a raw substring:
|
|
80
|
+
* `./x.hook.ts` and `x.hook.ts` are the same file (so the entry is replaced,
|
|
81
|
+
* which also de-duplicates settings written by an older version), while
|
|
82
|
+
* `x.hook.ts` and `my-x.hook.ts` are not (a substring test said they were).
|
|
83
|
+
*/
|
|
53
84
|
function managesHook(entry, hookPath) {
|
|
54
|
-
|
|
85
|
+
const ref = normalizeHookRef(hookPath);
|
|
86
|
+
return entry.hooks.some((h) => h.command
|
|
87
|
+
.split(/\s+/)
|
|
88
|
+
.some((token) => token !== "" && normalizeHookRef(token) === ref));
|
|
55
89
|
}
|
|
56
90
|
/**
|
|
57
91
|
* Idempotently merge a compiled hook's block into an existing `settings.json`
|
|
@@ -77,7 +111,8 @@ function toTomlEntries(entries) {
|
|
|
77
111
|
function mergeHooksToml(existing, compiled, hookPath) {
|
|
78
112
|
const hooks = { ...(existing.hooks ?? {}) };
|
|
79
113
|
for (const [event, entries] of Object.entries(compiled)) {
|
|
80
|
-
|
|
114
|
+
// Same canonical-path keying as the JSON merge (one flat command per entry).
|
|
115
|
+
const kept = (hooks[event] ?? []).filter((e) => !managesHook({ hooks: [{ type: "command", command: e.command }] }, hookPath));
|
|
81
116
|
hooks[event] = [...kept, ...toTomlEntries(entries)];
|
|
82
117
|
}
|
|
83
118
|
return { ...existing, hooks };
|
package/dist/hook.d.ts
CHANGED
|
@@ -50,5 +50,5 @@ export type { Decision, HookMode, GateAction, CommandView, PathView, ResponseVie
|
|
|
50
50
|
export { provide, dangerously, defineProvider, provider, } from "./core/hook-providers.js";
|
|
51
51
|
export type { ProviderName, ProviderResults, HookCtx, NeedSpec, InlineProvider, RegisteredProvider, RegisteredRef, ProviderRegistry, } from "./core/hook-providers.js";
|
|
52
52
|
export { leafCommandsNormalized } from "./core/bash-effects.js";
|
|
53
|
-
export type { NormalizedLeaf } from "./core/bash-effects.js";
|
|
53
|
+
export type { NormalizedLeaf, LeafRedirect } from "./core/bash-effects.js";
|
|
54
54
|
//# sourceMappingURL=hook.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type AnyHook } from "./core/hook-program.js";
|
|
2
|
+
/**
|
|
3
|
+
* Load a compiled-hook program's default export from `file`.
|
|
4
|
+
*
|
|
5
|
+
* JS module formats (`.mjs`/`.cjs`/`.js`) load via dynamic import directly; a
|
|
6
|
+
* TypeScript hook (`.ts`/`.mts`/`.cts`) loads only under a TS-capable runtime
|
|
7
|
+
* (tsx, or Node >= 23.6 with type stripping) — otherwise the error says so and
|
|
8
|
+
* points at authoring the hook as `.mjs`.
|
|
9
|
+
*
|
|
10
|
+
* ```js
|
|
11
|
+
* import { loadHook, assertHookDenies } from "vigiles/testing";
|
|
12
|
+
*
|
|
13
|
+
* const guard = await loadHook(".vigiles/hooks/guard.mjs");
|
|
14
|
+
* assertHookDenies(guard, {
|
|
15
|
+
* tool_name: "Bash",
|
|
16
|
+
* tool_input: { command: "git push --force" },
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @throws {HookCompileError} when the file can't be imported, or has no
|
|
21
|
+
* default-exported hook program.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadHook(file: string): Promise<AnyHook>;
|
|
24
|
+
//# sourceMappingURL=load-hook.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadHook = loadHook;
|
|
4
|
+
/**
|
|
5
|
+
* `loadHook` — load a compiled-hook program from a FILE.
|
|
6
|
+
*
|
|
7
|
+
* The in-process assertions (`assertHookDenies` / `assertHookAllows` /
|
|
8
|
+
* `assertHookNotices` / `assertHookSilent`) take the hook OBJECT, but a
|
|
9
|
+
* `.harness.mjs` test only has the hook's PATH — and the loader that turns one
|
|
10
|
+
* into the other lived privately inside `cli.ts`. So the intended in-process test
|
|
11
|
+
* path was unreachable from the file format `vigiles test` actually runs, and
|
|
12
|
+
* authors fell back to spawning the runtime as a subprocess: exactly the plumbing
|
|
13
|
+
* compiled hooks exist to remove. Measured 2026-08-03.
|
|
14
|
+
*
|
|
15
|
+
* This is now the ONE loader — the CLI runtime calls it too, so a hook that loads
|
|
16
|
+
* in a test loads identically in production (one loader, no drift).
|
|
17
|
+
*/
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const node_url_1 = require("node:url");
|
|
20
|
+
const hook_program_js_1 = require("./core/hook-program.js");
|
|
21
|
+
/**
|
|
22
|
+
* Load a compiled-hook program's default export from `file`.
|
|
23
|
+
*
|
|
24
|
+
* JS module formats (`.mjs`/`.cjs`/`.js`) load via dynamic import directly; a
|
|
25
|
+
* TypeScript hook (`.ts`/`.mts`/`.cts`) loads only under a TS-capable runtime
|
|
26
|
+
* (tsx, or Node >= 23.6 with type stripping) — otherwise the error says so and
|
|
27
|
+
* points at authoring the hook as `.mjs`.
|
|
28
|
+
*
|
|
29
|
+
* ```js
|
|
30
|
+
* import { loadHook, assertHookDenies } from "vigiles/testing";
|
|
31
|
+
*
|
|
32
|
+
* const guard = await loadHook(".vigiles/hooks/guard.mjs");
|
|
33
|
+
* assertHookDenies(guard, {
|
|
34
|
+
* tool_name: "Bash",
|
|
35
|
+
* tool_input: { command: "git push --force" },
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @throws {HookCompileError} when the file can't be imported, or has no
|
|
40
|
+
* default-exported hook program.
|
|
41
|
+
*/
|
|
42
|
+
async function loadHook(file) {
|
|
43
|
+
const abs = (0, node_path_1.resolve)(process.cwd(), file);
|
|
44
|
+
let mod;
|
|
45
|
+
try {
|
|
46
|
+
mod = (await import((0, node_url_1.pathToFileURL)(abs).href));
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (/\.(?:m|c)?ts$/.test(file)) {
|
|
50
|
+
throw new hook_program_js_1.HookCompileError(`Cannot load TypeScript hook "${file}" in this Node runtime. Run under ` +
|
|
51
|
+
`tsx (npx tsx …) / Node >= 23.6, or author the hook as a .mjs file.`);
|
|
52
|
+
}
|
|
53
|
+
throw new hook_program_js_1.HookCompileError(`Cannot load hook "${file}": ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
// Unwrap the ESM/CJS double-default that `export default` can produce.
|
|
56
|
+
const program = mod.default?.default ?? mod.default;
|
|
57
|
+
if (!program || typeof program !== "object") {
|
|
58
|
+
throw new hook_program_js_1.HookCompileError(`${file} has no default-exported hook program ` +
|
|
59
|
+
`(use \`export default defineHook({…})\`).`);
|
|
60
|
+
}
|
|
61
|
+
return program;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=load-hook.js.map
|
package/dist/scan.js
CHANGED
|
@@ -457,8 +457,12 @@ function formatScanReport(r) {
|
|
|
457
457
|
r.pluginLayoutIssues.length +
|
|
458
458
|
r.hookBlockFindings.length +
|
|
459
459
|
r.hookMatcherFindings.length +
|
|
460
|
-
// HARD trifectas
|
|
461
|
-
//
|
|
460
|
+
// Only HARD trifectas (✗) count as STRUCTURAL defects here. Both severities are
|
|
461
|
+
// now GRADED (see trifectaExposure — an inherits-all unit holds the three legs
|
|
462
|
+
// implicitly and everything else besides), but this line tallies structural
|
|
463
|
+
// defects, not the safety axis; promoting every idiomatic inherits-all unit to
|
|
464
|
+
// "broken" in the CLI summary is a separate, louder call. The delegation-trifecta
|
|
465
|
+
// ⚠ risk is ungraded and does NOT count either.
|
|
462
466
|
r.trifectaFindings.filter((t) => t.finding.severity === "hard").length;
|
|
463
467
|
out.push(broken === 0
|
|
464
468
|
? "✓ no structural issues found"
|
package/dist/score-core.d.ts
CHANGED
|
@@ -36,6 +36,15 @@ export declare const W_DANGLING_REF = 8;
|
|
|
36
36
|
export declare const W_OVERLAP = 8;
|
|
37
37
|
export declare const W_NO_CONTRACT = 5;
|
|
38
38
|
export declare const W_TRIFECTA = 10;
|
|
39
|
+
/**
|
|
40
|
+
* CAP on the TOTAL lethal-trifecta penalty, charged against the SHARE of the
|
|
41
|
+
* model-invocable surface that holds the trifecta (see {@link trifectaExposure}).
|
|
42
|
+
* A pure per-unit count saturates the clamp on any sizeable harness — which is
|
|
43
|
+
* how the old model reported a HARDENED harness as strictly worse than its
|
|
44
|
+
* unhardened self. Same value as the `feature-dev` anchor (3-of-3 units → −30),
|
|
45
|
+
* so a fully-exposed harness still lands on a C-band ding, never an automatic F.
|
|
46
|
+
*/
|
|
47
|
+
export declare const W_TRIFECTA_MAX = 30;
|
|
39
48
|
/**
|
|
40
49
|
* The one canonical, jargon-free finding string for a HARD lethal-trifecta unit —
|
|
41
50
|
* shared by the Safety category card (audit-score) AND the verdict sentence / overall
|
|
@@ -53,7 +62,51 @@ export interface Deduction {
|
|
|
53
62
|
readonly n: number;
|
|
54
63
|
readonly weight: number;
|
|
55
64
|
readonly label: string;
|
|
65
|
+
/**
|
|
66
|
+
* TOTAL penalty for this deduction, overriding the default `n × weight`. Only
|
|
67
|
+
* used where the cost is not linear in the count — today that's the trifecta
|
|
68
|
+
* exposure penalty, which is capped against the share of the surface affected
|
|
69
|
+
* (see {@link trifectaExposure}). `n` still drives the human-readable label.
|
|
70
|
+
*/
|
|
71
|
+
readonly points?: number;
|
|
72
|
+
}
|
|
73
|
+
/** The graded lethal-trifecta exposure of a report. */
|
|
74
|
+
export interface TrifectaExposure {
|
|
75
|
+
/** Units holding all three legs — EXPLICIT (`hard`) and INHERITED alike. */
|
|
76
|
+
readonly exposed: number;
|
|
77
|
+
/** Model-invocable units that could hold them (subagents + non-user-invoked skills). */
|
|
78
|
+
readonly assessable: number;
|
|
79
|
+
/** The graded penalty, `min(W_TRIFECTA × exposed, W_TRIFECTA_MAX × exposed/assessable)`. */
|
|
80
|
+
readonly penalty: number;
|
|
56
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The lethal-trifecta exposure a report incurs — the ONE number the Safety ring
|
|
84
|
+
* and the overall grade both read.
|
|
85
|
+
*
|
|
86
|
+
* TWO properties this model must have, both learned the hard way from dogfooding
|
|
87
|
+
* (2026-08-03, a 35-skill repo):
|
|
88
|
+
*
|
|
89
|
+
* 1. **MONOTONE in risk.** A unit that INHERITS all tools holds the full trifecta
|
|
90
|
+
* *implicitly* and is strictly WORSE than one whose explicit `allowed-tools`
|
|
91
|
+
* happens to name all three legs — it holds every other capability too. The old
|
|
92
|
+
* model graded the explicit case at −10 and left the inherited case UNGRADED, so
|
|
93
|
+
* declaring a tool contract (a genuine risk reduction) could only ever LOWER the
|
|
94
|
+
* score: a repo measured 70 with 31 of 35 units inheriting everything, then 0
|
|
95
|
+
* after `allowed-tools` was added to all 35 and the units holding the full
|
|
96
|
+
* trifecta fell 35/35 → 17/35. The tool called the safer configuration strictly
|
|
97
|
+
* worse and said nothing about the unsafe one. So BOTH severities count here.
|
|
98
|
+
*
|
|
99
|
+
* 2. **Non-saturating.** With a flat per-unit weight, 35 exposed units and 17
|
|
100
|
+
* exposed units both blow past the clamp and score 0 — halving your exposure
|
|
101
|
+
* shows up as no change at all. So the total is ALSO capped against the SHARE
|
|
102
|
+
* of the model-invocable surface that is exposed: a 35-unit harness with 3
|
|
103
|
+
* exposed units is genuinely safer than a 3-unit harness where all 3 are.
|
|
104
|
+
*
|
|
105
|
+
* The `min` of the two keeps every existing calibration anchor for small harnesses
|
|
106
|
+
* (1 exposed unit of 1 → −10; the `feature-dev` 3-of-3 shape → −30 → C) while
|
|
107
|
+
* giving a large harness real resolution (34/35 → −29, 17/35 → −15).
|
|
108
|
+
*/
|
|
109
|
+
export declare function trifectaExposure(r: ScanReport): TrifectaExposure;
|
|
57
110
|
/**
|
|
58
111
|
* The COMPLETE graded-penalty list a report incurs — the single source of truth
|
|
59
112
|
* BOTH the leaderboard's single health number and the audit's category rings
|
package/dist/score-core.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.TRIFECTA_LABEL = exports.W_TRIFECTA = exports.W_NO_CONTRACT = exports.W_OVERLAP = exports.W_DANGLING_REF = exports.W_NO_DESCRIPTION = exports.W_MISSING_HOOK = void 0;
|
|
3
|
+
exports.TRIFECTA_LABEL = exports.W_TRIFECTA_MAX = exports.W_TRIFECTA = exports.W_NO_CONTRACT = exports.W_OVERLAP = exports.W_DANGLING_REF = exports.W_NO_DESCRIPTION = exports.W_MISSING_HOOK = void 0;
|
|
4
4
|
exports.gradeFor = gradeFor;
|
|
5
|
+
exports.trifectaExposure = trifectaExposure;
|
|
5
6
|
exports.reportDeductions = reportDeductions;
|
|
6
7
|
exports.isEmptyMachine = isEmptyMachine;
|
|
7
8
|
exports.computeIntegrityScore = computeIntegrityScore;
|
|
@@ -14,7 +15,16 @@ exports.W_NO_DESCRIPTION = 10; // a skill with no usable description → can't t
|
|
|
14
15
|
exports.W_DANGLING_REF = 8; // a referenced intra-plugin file that's missing → broken path
|
|
15
16
|
exports.W_OVERLAP = 8; // a description collision → the wrong skill fires
|
|
16
17
|
exports.W_NO_CONTRACT = 5; // generic small-footgun weight (disallowedTools typo, invalid model/color)
|
|
17
|
-
exports.W_TRIFECTA = 10; // a
|
|
18
|
+
exports.W_TRIFECTA = 10; // per-unit cost of a lethal-trifecta unit (all three legs, explicit OR inherited) → a prompt-injection exfil path. HALF the old 20: a DING, not a fail — a trifecta is a real risk worth surfacing in the grade, but official plugins ship the pattern by design, so it dents the score (e.g. feature-dev's 3-of-3 hard units → −30 → C) without a catastrophic F.
|
|
19
|
+
/**
|
|
20
|
+
* CAP on the TOTAL lethal-trifecta penalty, charged against the SHARE of the
|
|
21
|
+
* model-invocable surface that holds the trifecta (see {@link trifectaExposure}).
|
|
22
|
+
* A pure per-unit count saturates the clamp on any sizeable harness — which is
|
|
23
|
+
* how the old model reported a HARDENED harness as strictly worse than its
|
|
24
|
+
* unhardened self. Same value as the `feature-dev` anchor (3-of-3 units → −30),
|
|
25
|
+
* so a fully-exposed harness still lands on a C-band ding, never an automatic F.
|
|
26
|
+
*/
|
|
27
|
+
exports.W_TRIFECTA_MAX = 30;
|
|
18
28
|
/**
|
|
19
29
|
* The one canonical, jargon-free finding string for a HARD lethal-trifecta unit —
|
|
20
30
|
* shared by the Safety category card (audit-score) AND the verdict sentence / overall
|
|
@@ -28,8 +38,8 @@ exports.TRIFECTA_LABEL = 'unit(s) can read data, reach the web, and run commands
|
|
|
28
38
|
// Two things are advisory, NOT graded penalties (shown, never scored — see scoreReport):
|
|
29
39
|
// - untested surfaces — a hardening gap, not breakage.
|
|
30
40
|
// - an agent that inherits all tools (no `tools:` line) — see reportDeductions for why.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
41
|
+
// NB: an inherits-all TRIFECTA finding (severity "advisory") IS graded — see
|
|
42
|
+
// trifectaExposure. Only the tool-CONTRACT nudge above stays ungraded.
|
|
33
43
|
/** Map a 0–100 structural-health score to its letter grade (A ≥90 … F <60). */
|
|
34
44
|
function gradeFor(score) {
|
|
35
45
|
if (score >= 90)
|
|
@@ -42,6 +52,49 @@ function gradeFor(score) {
|
|
|
42
52
|
return "D";
|
|
43
53
|
return "F";
|
|
44
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The lethal-trifecta exposure a report incurs — the ONE number the Safety ring
|
|
57
|
+
* and the overall grade both read.
|
|
58
|
+
*
|
|
59
|
+
* TWO properties this model must have, both learned the hard way from dogfooding
|
|
60
|
+
* (2026-08-03, a 35-skill repo):
|
|
61
|
+
*
|
|
62
|
+
* 1. **MONOTONE in risk.** A unit that INHERITS all tools holds the full trifecta
|
|
63
|
+
* *implicitly* and is strictly WORSE than one whose explicit `allowed-tools`
|
|
64
|
+
* happens to name all three legs — it holds every other capability too. The old
|
|
65
|
+
* model graded the explicit case at −10 and left the inherited case UNGRADED, so
|
|
66
|
+
* declaring a tool contract (a genuine risk reduction) could only ever LOWER the
|
|
67
|
+
* score: a repo measured 70 with 31 of 35 units inheriting everything, then 0
|
|
68
|
+
* after `allowed-tools` was added to all 35 and the units holding the full
|
|
69
|
+
* trifecta fell 35/35 → 17/35. The tool called the safer configuration strictly
|
|
70
|
+
* worse and said nothing about the unsafe one. So BOTH severities count here.
|
|
71
|
+
*
|
|
72
|
+
* 2. **Non-saturating.** With a flat per-unit weight, 35 exposed units and 17
|
|
73
|
+
* exposed units both blow past the clamp and score 0 — halving your exposure
|
|
74
|
+
* shows up as no change at all. So the total is ALSO capped against the SHARE
|
|
75
|
+
* of the model-invocable surface that is exposed: a 35-unit harness with 3
|
|
76
|
+
* exposed units is genuinely safer than a 3-unit harness where all 3 are.
|
|
77
|
+
*
|
|
78
|
+
* The `min` of the two keeps every existing calibration anchor for small harnesses
|
|
79
|
+
* (1 exposed unit of 1 → −10; the `feature-dev` 3-of-3 shape → −30 → C) while
|
|
80
|
+
* giving a large harness real resolution (34/35 → −29, 17/35 → −15).
|
|
81
|
+
*/
|
|
82
|
+
function trifectaExposure(r) {
|
|
83
|
+
const exposed = r.trifectaFindings.length;
|
|
84
|
+
// The assessable surface mirrors the Safety ring's own n/a rule: subagents plus
|
|
85
|
+
// model-invocable skills (a user-invoked skill can't be hijacked by attacker
|
|
86
|
+
// content, so it is neither exposed nor assessable). Never below the exposed
|
|
87
|
+
// count — a finding always comes FROM a unit, so a report that carries findings
|
|
88
|
+
// without the units (a hand-built fixture) still charges for them.
|
|
89
|
+
const assessable = Math.max(r.agents.length + r.skills.filter((s) => !s.userInvoked).length, exposed);
|
|
90
|
+
if (exposed === 0 || assessable === 0) {
|
|
91
|
+
return { exposed, assessable, penalty: 0 };
|
|
92
|
+
}
|
|
93
|
+
const perUnit = exports.W_TRIFECTA * exposed;
|
|
94
|
+
// Never round a real finding away to a free 0 on a very large harness.
|
|
95
|
+
const share = Math.max(1, Math.round((exports.W_TRIFECTA_MAX * exposed) / assessable));
|
|
96
|
+
return { exposed, assessable, penalty: Math.min(perUnit, share) };
|
|
97
|
+
}
|
|
45
98
|
/**
|
|
46
99
|
* The COMPLETE graded-penalty list a report incurs — the single source of truth
|
|
47
100
|
* BOTH the leaderboard's single health number and the audit's category rings
|
|
@@ -55,17 +108,17 @@ function reportDeductions(r) {
|
|
|
55
108
|
const deadTools = r.agents.reduce((n, a) => n + a.toolIssues.length, 0);
|
|
56
109
|
const deadMcpTools = r.agents.reduce((n, a) => n + a.mcpToolIssues.length, 0);
|
|
57
110
|
const deadDisallowed = r.agents.reduce((n, a) => n + a.disallowedToolIssues.length, 0);
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
const hardTrifecta = r.trifectaFindings.filter((f) => f.finding.severity === "hard").length;
|
|
111
|
+
// Lethal-trifecta EXPOSURE — every unit holding all three legs, whether it
|
|
112
|
+
// declared them (`hard`) or inherited them (an inherits-all contract holds them
|
|
113
|
+
// implicitly AND everything else, so it can't be the cheaper of the two). Capped
|
|
114
|
+
// against the share of the surface affected so the model stays monotone in risk
|
|
115
|
+
// and doesn't saturate — see trifectaExposure for the measured failure this fixes.
|
|
116
|
+
const trifecta = trifectaExposure(r);
|
|
65
117
|
return [
|
|
66
118
|
{
|
|
67
|
-
n:
|
|
119
|
+
n: trifecta.exposed,
|
|
68
120
|
weight: exports.W_TRIFECTA,
|
|
121
|
+
points: trifecta.penalty,
|
|
69
122
|
label: exports.TRIFECTA_LABEL,
|
|
70
123
|
},
|
|
71
124
|
{
|
|
@@ -189,7 +242,7 @@ function computeIntegrityScore(deductions) {
|
|
|
189
242
|
for (const d of deductions) {
|
|
190
243
|
if (d.n <= 0)
|
|
191
244
|
continue;
|
|
192
|
-
penalty += d.n * d.weight;
|
|
245
|
+
penalty += d.points ?? d.n * d.weight;
|
|
193
246
|
}
|
|
194
247
|
return { score: Math.max(0, 100 - penalty), penalty };
|
|
195
248
|
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { HookRunResult, RunHookOptions, HookInput, HookOutput, HookProperty
|
|
|
15
15
|
export { runEval, measure, measureArms, measureTriggerRate, assertRates, assertPromptDiversity, checkPromptDiversity, checkReportToJUnit, formatCheckReport, formatEvalReport, formatTriggerRateReport, claudeEvalDriver, parseClaudeRun, stubSkillBody, } from "./eval.js";
|
|
16
16
|
export type { EvalArm, EvalDriver, EvalSpec, EvalReport, EvalUsage, MeasureSpec, ArmsMeasureSpec, ArmReport, ArmUsage, ArmsCheckReport, CheckRate, CheckReport, MetricStat, Metrics, ModelOutputParser, ParsedModelRun, PromptDiversityIssue, PromptTriggerStat, RunContext, RunOut, SelectionTrialResult, TriggerRateReport, TriggerRateSpec, AgentRunArgs, AgentRunner, } from "./eval.js";
|
|
17
17
|
export * from "./harness-assert.js";
|
|
18
|
+
export { loadHook } from "./load-hook.js";
|
|
18
19
|
export * from "./check.js";
|
|
19
20
|
export { hookFired } from "./check.js";
|
|
20
21
|
export { judge } from "./judge.js";
|
package/dist/testing.js
CHANGED
|
@@ -30,7 +30,7 @@ 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.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 = 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 = void 0;
|
|
34
34
|
// --- unit tier: runHook ---
|
|
35
35
|
var run_hook_js_1 = require("./run-hook.js");
|
|
36
36
|
Object.defineProperty(exports, "runHook", { enumerable: true, get: function () { return run_hook_js_1.runHook; } });
|
|
@@ -52,6 +52,14 @@ Object.defineProperty(exports, "claudeEvalDriver", { enumerable: true, get: func
|
|
|
52
52
|
Object.defineProperty(exports, "parseClaudeRun", { enumerable: true, get: function () { return eval_js_1.parseClaudeRun; } });
|
|
53
53
|
Object.defineProperty(exports, "stubSkillBody", { enumerable: true, get: function () { return eval_js_1.stubSkillBody; } });
|
|
54
54
|
__exportStar(require("./harness-assert.js"), exports);
|
|
55
|
+
// The compiled-hook LOADER. The in-process assertions above take the hook
|
|
56
|
+
// OBJECT, but a `.harness.mjs` test only has its PATH — without this the
|
|
57
|
+
// intended in-process test path is unreachable from the file format
|
|
58
|
+
// `vigiles test` actually runs, and authors fall back to spawning the runtime as
|
|
59
|
+
// a subprocess (the very plumbing compiled hooks exist to remove). Same loader
|
|
60
|
+
// the CLI runtime uses, so a hook that loads in a test loads identically in prod.
|
|
61
|
+
var load_hook_js_1 = require("./load-hook.js");
|
|
62
|
+
Object.defineProperty(exports, "loadHook", { enumerable: true, get: function () { return load_hook_js_1.loadHook; } });
|
|
55
63
|
// The declarative check vocabulary is now first-class at the front door. Its
|
|
56
64
|
// `hookFired` (a `Check<Trace>`) supersedes the legacy boolean predicate of the
|
|
57
65
|
// same name — the explicit re-export below wins over the two `export *`s.
|
package/dist/unit.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `vigiles/e2e`.
|
|
13
13
|
*/
|
|
14
14
|
export * from "./harness-assert.js";
|
|
15
|
+
export { loadHook } from "./load-hook.js";
|
|
15
16
|
export { runHook, parseHookOutput, decideHook, propertyHook, } from "./run-hook.js";
|
|
16
17
|
export type { HookInput, HookOutput, HookRunResult, RunHookOptions, HookPropertyResult, } from "./run-hook.js";
|
|
17
18
|
export * from "./check.js";
|
package/dist/unit.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.hookFired = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = void 0;
|
|
17
|
+
exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.hookFired = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.loadHook = void 0;
|
|
18
18
|
/**
|
|
19
19
|
* `vigiles/unit` — the **no-capability** harness-testing surface.
|
|
20
20
|
*
|
|
@@ -29,6 +29,11 @@ exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblocke
|
|
|
29
29
|
* `vigiles/e2e`.
|
|
30
30
|
*/
|
|
31
31
|
__exportStar(require("./harness-assert.js"), exports);
|
|
32
|
+
// The compiled-hook loader belongs to the no-capability tier: it imports a local
|
|
33
|
+
// module and nothing else. Without it the in-process assertions above are
|
|
34
|
+
// unreachable from a test that only knows the hook's PATH.
|
|
35
|
+
var load_hook_js_1 = require("./load-hook.js");
|
|
36
|
+
Object.defineProperty(exports, "loadHook", { enumerable: true, get: function () { return load_hook_js_1.loadHook; } });
|
|
32
37
|
var run_hook_js_1 = require("./run-hook.js");
|
|
33
38
|
Object.defineProperty(exports, "runHook", { enumerable: true, get: function () { return run_hook_js_1.runHook; } });
|
|
34
39
|
Object.defineProperty(exports, "parseHookOutput", { enumerable: true, get: function () { return run_hook_js_1.parseHookOutput; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.13.0",
|
|
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",
|