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.
@@ -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 is GRADED into the overall at a REDUCED weight
18
- * (`W_TRIFECTA=10`, HALF the old 20 a DING, not a fail: it dents the grade
19
- * without a catastrophic F for a pattern official plugins ship by design); a
20
- * `"advisory"` (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
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
@@ -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 is GRADED into the overall at a REDUCED weight
22
- * (`W_TRIFECTA=10`, HALF the old 20 a DING, not a fail: it dents the grade
23
- * without a catastrophic F for a pattern official plugins ship by design); a
24
- * `"advisory"` (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
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. A `"hard"` finding (an explicit contract naming all three
187
- * capability legs) is a prompt-injection exfil path and is GRADED at a REDUCED
188
- * weight (`W_TRIFECTA=10` each HALF the old 20, a DING not a fail; the same
189
- * weight `reportDeductions` sums into the overall, so the ring and the headline
190
- * agree). A `"advisory"` finding (inherits-all) is SHOWN in the ring's findings
191
- * but NOT graded aligned with the inherits-all-is-advisory stance.
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 modelInvocableSkills = r.skills.filter((s) => !s.userInvoked).length;
201
- const assessable = r.agents.length + modelInvocableSkills;
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 hard = r.trifectaFindings.filter((f) => f.finding.severity === "hard");
211
- const { score, findings } = scoreFrom([
212
- {
213
- n: hard.length,
214
- weight: score_core_js_1.W_TRIFECTA,
215
- label: score_core_js_1.TRIFECTA_LABEL,
216
- },
217
- ]);
218
- // inherits-all trifecta findings are ADVISORY: surfaced as a maximal-blast-radius
219
- // note but never graded (mirrors the Structure inherits-all advisory).
220
- const advisory = r.trifectaFindings
221
- .filter((f) => f.finding.severity === "advisory")
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: [...findings, ...advisory],
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. JS module formats
4570
- * (`.mjs`/`.cjs`/`.js`) load via dynamic import directly; a TypeScript hook
4571
- * loads only under a TS-capable runtime (tsx / Node >= 23.6) — otherwise an
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
- async function loadHookProgram(file) {
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 PATH), so recompiling updates in
4647
- * place and never clobbers the user's own hooks.
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 ${file}`,
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, file)
4672
- : (0, hook_install_js_1.mergeHooksJson)(existing, compiled.hooks, file);
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
- const leaf = normalizeCallExpr(node);
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
- /** Normalize a single CallExpr node to a {@link NormalizedLeaf}, or null if it isn't one / has a dynamic head. */
708
- function normalizeCallExpr(node) {
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 references a path under one of the prefixes (e.g.
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
  }
@@ -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[];
@@ -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) {
@@ -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
@@ -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 PATH, so recompiling updates in place and never duplicates,
22
- * while a user's own hand-written hooks are preserved untouched. One source dir
23
- * also means basenames are unique, so the stamp can key on the basename safely.
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
- /** True when an entry's command routes through the runtime for `hookPath`. */
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
- return entry.hooks.some((h) => h.command.includes(hookPath));
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
- const kept = (hooks[event] ?? []).filter((e) => !e.command.includes(hookPath));
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 render and are graded into the score, so they count; the
461
- // ADVISORY (inherits-all) ones and the delegation-trifecta ⚠ risk do NOT.
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"
@@ -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
@@ -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 HARD lethal-trifecta contract (all three legs, explicit) → 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 hard units → −30 → C) without a catastrophic F.
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
- // - an inherits-all (severity "advisory") trifecta findingshown by the Safety
32
- // ring but never scored; only the HARD, explicit all-three-legs finding grades.
41
+ // NB: an inherits-all TRIFECTA finding (severity "advisory") IS gradedsee
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
- // HARD lethal-trifecta findings only an EXPLICIT contract naming all three
59
- // legs (a prompt-injection exfil path). Graded at W_TRIFECTA=10 (HALF the old
60
- // 20): a DING that surfaces a real risk in the grade without a catastrophic F
61
- // for an accepted design pattern official plugins ship. Advisory (inherits-all)
62
- // trifecta findings are surfaced but NEVER graded (aligned with the inherits-all
63
- // stance), so they're excluded here.
64
- const hardTrifecta = r.trifectaFindings.filter((f) => f.finding.severity === "hard").length;
111
+ // Lethal-trifecta EXPOSUREevery 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: hardTrifecta,
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.12.1",
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",