vigiles 4.0.2 → 5.0.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.
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_INTERCEPT_REASON = exports.INTERCEPT_TOOLS_ENV = void 0;
4
+ exports.decideIntercept = decideIntercept;
5
+ exports.interceptHookDecision = interceptHookDecision;
6
+ exports.buildInterceptSettings = buildInterceptSettings;
7
+ exports.serializeIntercepts = serializeIntercepts;
8
+ exports.parseIntercepts = parseIntercepts;
9
+ /**
10
+ * Tool interception — the eval-tier half of the tool-call spy.
11
+ *
12
+ * The eval tier drives the REAL model, so the agent's tool decisions are genuine.
13
+ * But letting a side-effecting tool actually run — a paid image API, `git push`,
14
+ * spawning a paid subagent — makes the eval expensive and dangerous. An
15
+ * **intercept** lets the model emit the `tool_use` (so its arguments are captured
16
+ * in the `Trace`, where `toolWith` / `notTool` assert on them) while a PreToolUse
17
+ * hook **denies the real execution** with a block message. The run stays cheap and
18
+ * side-effect-free, but the agent's *decision* — the thing a completion grader
19
+ * can't see — is fully observable.
20
+ *
21
+ * This module is the PURE decision + wiring core, mirroring the agent-contract
22
+ * rail (`src/adapters/claude-code/agent-runtime.ts`):
23
+ *
24
+ * - `decideIntercept` — does this call get intercepted, and with what deny reason;
25
+ * - `buildInterceptSettings` — the PreToolUse hook fragment routing matched tools
26
+ * through `vigiles intercept-tool-hook`;
27
+ * - `serializeIntercepts` / `parseIntercepts` — the env round-trip (incl. RegExp
28
+ * matchers) the hook subprocess reads back.
29
+ *
30
+ * The hook denies via exit 2 + a stderr message (the same block mechanism the
31
+ * agent rail uses). IMPORTANT — Claude Code surfaces that to the model as a
32
+ * *blocked* call: the tool is intercepted (prevented), NOT executed, and the model
33
+ * is NOT handed a faked successful return. So this is **intercept-and-prevent +
34
+ * observe the attempt**, not a faithful tool mock: it's sound for "did the agent
35
+ * ATTEMPT X / call it with these args / push to the wrong branch" (first-attempt
36
+ * questions, where what happens after doesn't matter), and unsound for "stub the
37
+ * tool and let the trajectory continue as if it succeeded" (the model is told it
38
+ * was blocked, so a multi-step flow that needs the real result will derail). CC
39
+ * exposes no "skip execution but return this as success" primitive for arbitrary
40
+ * tools, so deny+reason is the closest available — with that ceiling.
41
+ */
42
+ const arg_match_js_1 = require("./arg-match.js");
43
+ /** Env var the spawned `vigiles intercept-tool-hook` reads its intercept list from. */
44
+ exports.INTERCEPT_TOOLS_ENV = "VIGILES_INTERCEPT_TOOLS";
45
+ /** The default denial reason — honest that the call was intercepted (prevented), NOT executed. */
46
+ exports.DEFAULT_INTERCEPT_REASON = "vigiles intercepted this tool call for testing — it was NOT executed. " +
47
+ "Do not retry it; treat the tool as unavailable and continue.";
48
+ /**
49
+ * Decide whether a tool call should be intercepted. Returns the first matching
50
+ * intercept's denial reason (preventing real execution), or `{ intercept: false }`
51
+ * to let the call run for real. Pure — the same logic `vigiles intercept-tool-hook`
52
+ * runs.
53
+ */
54
+ function decideIntercept(toolName, input, intercepts) {
55
+ for (const i of intercepts) {
56
+ if (i.tool !== toolName)
57
+ continue;
58
+ if (i.when && !(0, arg_match_js_1.matchesArgs)(input, i.when))
59
+ continue;
60
+ return {
61
+ intercept: true,
62
+ denyReason: i.denyReason ?? exports.DEFAULT_INTERCEPT_REASON,
63
+ };
64
+ }
65
+ return { intercept: false };
66
+ }
67
+ /**
68
+ * Decide from a raw PreToolUse event JSON (the hook's stdin). Parses `tool_name`
69
+ * + `tool_input`, then defers to {@link decideIntercept}. Malformed input or a
70
+ * missing tool name is a no-op (let it run) — never fail closed on a parse error.
71
+ */
72
+ function interceptHookDecision(rawEvent, intercepts) {
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(rawEvent);
76
+ }
77
+ catch {
78
+ return { intercept: false };
79
+ }
80
+ const tool = parsed.tool_name ?? "";
81
+ if (!tool)
82
+ return { intercept: false };
83
+ return decideIntercept(tool, parsed.tool_input ?? {}, intercepts);
84
+ }
85
+ function escapeRegex(s) {
86
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
+ }
88
+ function uniqueToolNames(intercepts) {
89
+ return [...new Set(intercepts.map((i) => i.tool))];
90
+ }
91
+ /**
92
+ * Build the PreToolUse hook fragment that routes every intercepted tool through
93
+ * `vigiles intercept-tool-hook`. The `matcher` is a CC tool-name regex over the
94
+ * union of intercepted tool names (each escaped), so unrelated tools are never
95
+ * intercepted. Merge the result into an arm's `settings`; the intercept list
96
+ * itself travels in the {@link INTERCEPT_TOOLS_ENV} env var (see
97
+ * {@link serializeIntercepts}).
98
+ */
99
+ function buildInterceptSettings(intercepts, opts = {}) {
100
+ const command = opts.command ?? "npx vigiles intercept-tool-hook";
101
+ const matcher = uniqueToolNames(intercepts).map(escapeRegex).join("|");
102
+ return {
103
+ hooks: {
104
+ PreToolUse: [{ matcher, hooks: [{ type: "command", command }] }],
105
+ },
106
+ };
107
+ }
108
+ function isWireRegex(v) {
109
+ return typeof v === "object" && v !== null && "re" in v;
110
+ }
111
+ function encodeMatcher(m) {
112
+ const out = {};
113
+ for (const [k, v] of Object.entries(m)) {
114
+ out[k] = v instanceof RegExp ? { re: v.source, flags: v.flags } : v;
115
+ }
116
+ return out;
117
+ }
118
+ function decodeMatcher(w) {
119
+ const out = {};
120
+ for (const [k, v] of Object.entries(w)) {
121
+ out[k] = isWireRegex(v) ? new RegExp(v.re, v.flags) : v;
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Serialize an intercept list to a JSON string for {@link INTERCEPT_TOOLS_ENV}.
127
+ * RegExp matchers are encoded as `{ re, flags }` so they round-trip exactly (a
128
+ * plain `JSON.stringify` would drop them to `{}`).
129
+ */
130
+ function serializeIntercepts(intercepts) {
131
+ return JSON.stringify(intercepts.map((i) => ({
132
+ tool: i.tool,
133
+ denyReason: i.denyReason,
134
+ when: i.when ? encodeMatcher(i.when) : undefined,
135
+ })));
136
+ }
137
+ /** Parse an intercept list from the env JSON (tolerant — a bad entry is skipped). */
138
+ function parseIntercepts(json) {
139
+ let data;
140
+ try {
141
+ data = JSON.parse(json);
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ if (!Array.isArray(data))
147
+ return [];
148
+ const out = [];
149
+ for (const item of data) {
150
+ if (item === null || typeof item !== "object")
151
+ continue;
152
+ const o = item;
153
+ if (typeof o.tool !== "string")
154
+ continue;
155
+ out.push({
156
+ tool: o.tool,
157
+ denyReason: typeof o.denyReason === "string" ? o.denyReason : undefined,
158
+ when: o.when !== null && typeof o.when === "object"
159
+ ? decodeMatcher(o.when)
160
+ : undefined,
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+ //# sourceMappingURL=tool-intercept.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A single fake binary: shadow the tool named `name` on PATH so that any
3
+ * invocation prints `stdout` (default `""`), writes `stderr` (default none) to
4
+ * fd 2, and exits with `exitCode` (default 0). Argv is ignored in the MVP.
5
+ */
6
+ export interface ToolStub {
7
+ /** The binary name to shadow on PATH (e.g. `"gh"`, `"psql"`). */
8
+ readonly name: string;
9
+ /** Canned stdout the fake prints. Default `""`. */
10
+ readonly stdout?: string;
11
+ /** Canned stderr the fake writes to fd 2. Default: none. */
12
+ readonly stderr?: string;
13
+ /** Exit code the fake returns. Default `0`. */
14
+ readonly exitCode?: number;
15
+ }
16
+ /**
17
+ * Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
18
+ * the decoded bytes with no added trailing newline and no backslash/`-n`
19
+ * surprises, so the fixture round-trips byte-for-byte.
20
+ */
21
+ export declare function renderToolStub(stub: ToolStub): string;
22
+ /**
23
+ * Write one executable POSIX shell stub per entry into `binDir` (which must
24
+ * already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
25
+ * is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
26
+ */
27
+ export declare function writeToolStubs(binDir: string, stubs: readonly ToolStub[]): void;
28
+ /**
29
+ * Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
30
+ * and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
31
+ * over the real binaries, and removes it when done (it lives under `parentDir`,
32
+ * so a `parentDir` cleanup also clears it).
33
+ */
34
+ export declare function stubBinDir(stubs: readonly ToolStub[], parentDir: string): string;
35
+ //# sourceMappingURL=tool-stub.d.ts.map
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderToolStub = renderToolStub;
4
+ exports.writeToolStubs = writeToolStubs;
5
+ exports.stubBinDir = stubBinDir;
6
+ /**
7
+ * vigiles — **tool stubs on PATH** (rung R2 of the eval coverage model).
8
+ *
9
+ * A skill/hook/agent often calls a CLI tool (`psql`, `redis-cli`, `gh`, `git`,
10
+ * `z3`, …) and then works with the RESULT. To test that downstream logic without
11
+ * a live service, you SHADOW the real binary on PATH with a fake that emits a
12
+ * **recorded / author-provided canned result** — a standard PATH-shim +
13
+ * VCR-style record/replay technique, not a novel invention. See
14
+ * `research/eval-coverage-and-isolation.md` (the three-rung model).
15
+ *
16
+ * This module is the REPLAY half: write a fake executable per tool that prints a
17
+ * canned stdout/stderr and exits a canned code. A record-from-real-tool half (run
18
+ * the real binary once at a known version, capture its output as the fixture) is
19
+ * a documented follow-on — NOT implemented here.
20
+ *
21
+ * CRITICAL: the canned outputs are author/recorded fixtures, **never**
22
+ * model-synthesized — a synthesized `gh`/`git` output looks plausible but
23
+ * diverges from the real tool/version, producing false confidence (a green test
24
+ * against a fiction).
25
+ *
26
+ * MVP scope: one canned result per binary; argv is IGNORED (every invocation of
27
+ * the stub returns the same result). Argv-matching (different output per
28
+ * sub-command / flags) is a follow-on.
29
+ */
30
+ const node_fs_1 = require("node:fs");
31
+ const node_path_1 = require("node:path");
32
+ /**
33
+ * Encode a string as a single-line base64 literal safe to embed verbatim inside a
34
+ * POSIX shell script.
35
+ *
36
+ * We base64-encode-and-decode the canned content (rather than interpolating it
37
+ * raw, or `cat`ing a sibling data file) because base64's alphabet is a strict
38
+ * subset of `[A-Za-z0-9+/=]` — it can never contain a quote, `$`, backtick,
39
+ * newline, `;`, or any other shell metacharacter, so the embedded literal is
40
+ * injection-proof regardless of the fixture's bytes. The script decodes it back
41
+ * with `base64 -d` at run time, so the original bytes round-trip exactly. This is
42
+ * simpler than a sibling data file (one self-contained script, nothing else to
43
+ * write/clean up) and strictly safer than quoting.
44
+ */
45
+ function b64(s) {
46
+ return Buffer.from(s, "utf-8").toString("base64");
47
+ }
48
+ /**
49
+ * Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
50
+ * the decoded bytes with no added trailing newline and no backslash/`-n`
51
+ * surprises, so the fixture round-trips byte-for-byte.
52
+ */
53
+ function renderToolStub(stub) {
54
+ const lines = ["#!/bin/sh"];
55
+ // stdout: decode the base64 literal straight to stdout. We pipe `base64 -d`'s
56
+ // output directly (NOT through `printf '%s' "$(...)"`) because command
57
+ // substitution strips trailing newlines — piping preserves the bytes exactly.
58
+ if (stub.stdout !== undefined && stub.stdout !== "") {
59
+ lines.push(`printf '%s' '${b64(stub.stdout)}' | base64 -d`);
60
+ }
61
+ // stderr: same, redirected to fd 2.
62
+ if (stub.stderr !== undefined && stub.stderr !== "") {
63
+ lines.push(`printf '%s' '${b64(stub.stderr)}' | base64 -d >&2`);
64
+ }
65
+ lines.push(`exit ${String(stub.exitCode ?? 0)}`);
66
+ return lines.join("\n") + "\n";
67
+ }
68
+ /**
69
+ * Write one executable POSIX shell stub per entry into `binDir` (which must
70
+ * already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
71
+ * is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
72
+ */
73
+ function writeToolStubs(binDir, stubs) {
74
+ for (const stub of stubs) {
75
+ const file = (0, node_path_1.join)(binDir, stub.name);
76
+ (0, node_fs_1.writeFileSync)(file, renderToolStub(stub), "utf-8");
77
+ (0, node_fs_1.chmodSync)(file, 0o755);
78
+ }
79
+ }
80
+ /**
81
+ * Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
82
+ * and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
83
+ * over the real binaries, and removes it when done (it lives under `parentDir`,
84
+ * so a `parentDir` cleanup also clears it).
85
+ */
86
+ function stubBinDir(stubs, parentDir) {
87
+ (0, node_fs_1.mkdirSync)(parentDir, { recursive: true });
88
+ const binDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(parentDir, "vigiles-stub-bin-"));
89
+ writeToolStubs(binDir, stubs);
90
+ return binDir;
91
+ }
92
+ //# sourceMappingURL=tool-stub.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "4.0.2",
3
+ "version": "5.0.0",
4
4
  "description": "Compile .spec.ts files to instruction files (CLAUDE.md, AGENTS.md) with linter cross-referencing",
5
5
  "bin": {
6
6
  "vigiles": "dist/cli.js"
@@ -9,31 +9,20 @@
9
9
  "types": "./dist/core/spec.d.ts",
10
10
  "exports": {
11
11
  ".": "./dist/core/spec.js",
12
+ "./spec": "./dist/core/spec.js",
13
+ "./linting": "./dist/linting.js",
14
+ "./testing": "./dist/testing.js",
12
15
  "./unit": "./dist/unit.js",
13
16
  "./integration": "./dist/integration.js",
14
17
  "./e2e": "./dist/e2e.js",
15
- "./linting": "./dist/linting.js",
16
- "./testing": "./dist/testing.js",
17
18
  "./claude-code": "./dist/claude-code.js",
18
19
  "./codex": "./dist/codex.js",
19
20
  "./adapter": "./dist/adapter.js",
20
- "./spec": "./dist/core/spec.js",
21
- "./compile": "./dist/core/compile.js",
22
- "./linters": "./dist/core/linters.js",
23
- "./eval": "./dist/eval.js",
24
- "./harness-test": "./dist/harness-test.js",
25
- "./harness-assert": "./dist/harness-assert.js",
26
- "./run-hook": "./dist/run-hook.js",
27
- "./plugin-loader": "./dist/adapters/claude-code/plugin-loader.js",
28
- "./mcp": "./dist/core/mcp.js",
29
- "./judge": "./dist/judge.js",
30
- "./mock-model": "./dist/mock-model.js",
31
21
  "./vitest": {
32
22
  "types": "./dist/vitest.d.mts",
33
23
  "default": "./dist/vitest.mjs"
34
24
  },
35
- "./jest": "./dist/jest.js",
36
- "./check": "./dist/check.js"
25
+ "./jest": "./dist/jest.js"
37
26
  },
38
27
  "files": [
39
28
  "dist/**/*.js",
@@ -67,10 +56,15 @@
67
56
  "test:vitest": "npm run build && vitest run --project runners",
68
57
  "test:jest": "npm run build && jest",
69
58
  "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json",
70
- "demo:plugin": "npm run build && node examples/plugin-test-demo.mjs"
59
+ "demo:plugin": "npm run build && node examples/plugin-test-demo.mjs",
60
+ "api:report": "npm run build && node scripts/api-extractor.mjs --local",
61
+ "api:check": "npm run build && node scripts/api-extractor.mjs",
62
+ "docs:api": "npm run api:report && api-documenter markdown -i temp -o api-reference"
71
63
  },
72
64
  "devDependencies": {
73
65
  "@eslint/js": "^10.0.1",
66
+ "@microsoft/api-documenter": "^7.30.7",
67
+ "@microsoft/api-extractor": "^7.58.9",
74
68
  "@types/js-yaml": "^4.0.9",
75
69
  "@types/minimatch": "^5.1.2",
76
70
  "@types/node": "^20.19.39",
@@ -22,7 +22,8 @@ Match what you're testing to the cheapest tier that can answer it:
22
22
  | "Is the hook actually **wired into** the assembled plugin and does it fire in a real session?" | **Deterministic** | free, no API key (real `claude` + scripted mock) | `runHarnessTest` + `scriptModel` |
23
23
  | "Did the injected context (a SessionStart hook, a `/command`) actually **reach the model**?" | **Deterministic** | free, no API key | `runHarnessTest` → `trace.modelRequests` / `assertRequestContains` |
24
24
  | "Does this skill's **description trigger** when it should (recall) **and stay quiet** when it shouldn't (precision)?" | **Eval** | **paid** (real model) | `measureTriggerRate` (+ `irrelevantPrompts`) → `assertTriggerRate({ min, maxFalsePositive })` |
25
- | "Does this harness change **move what the agent does**?" (A/B, signal vs noise) | **Eval** | **paid** (real model) | `runEval` + `assertSignificant` |
25
+ | "Is this exact skill's **output any good**?" — absolute quality, no on/off baseline (the default for testing one skill) | **Eval** | **paid** (real model) | `measure({ checks: [judged(rubric)] })` `assertRates({ min })` |
26
+ | "Does this harness change **move what the agent does**, _relative_ to off?" — A/B lift, regression, signal vs noise | **Eval** | **paid** (real model) | `runEval` (arms) + `assertSignificant` |
26
27
 
27
28
  Most harness questions — block/allow, wired-in, context-landed — never need a
28
29
  model. Only "does the model trigger / behave differently" needs the eval tier.
@@ -30,6 +31,37 @@ model. Only "does the model trigger / behave differently" needs the eval tier.
30
31
  If the unit and deterministic tiers can both answer it, **prefer unit**: it's
31
32
  faster and reaches events the deterministic mock can't drive.
32
33
 
34
+ ## Step 0.5 — Set honest expectations (what's testable, and at what cost)
35
+
36
+ Be explicit with the user about which bucket each surface falls into — never let
37
+ "we'll test it" hide whether that's free, sub-priced, or needs a container. Every
38
+ surface sorts into one of three buckets:
39
+
40
+ - **A — Free & deterministic** (no model, runs in CI on every commit): a hook's
41
+ block/allow decision (`runHook`), a tool-contract / "did NOT call the forbidden
42
+ tool" check, structural facts (`vigiles scan`), and **record-replay** of any tool
43
+ a skill shells out to (record the real result once, replay it via a PATH stub).
44
+ - **B — Model-gated, on your subscription** (real model, **no metered API**): does a
45
+ skill's description **fire** (`measureTriggerRate`, recall + precision) **and**
46
+ does its guidance actually **produce good output** (score it directly:
47
+ `measure({ checks: [judged(rubric)] })` + `assertRates` — the absolute oracle;
48
+ use a `runEval` A/B on-vs-off only when you need the _relative_ lift). This is
49
+ the half a **prose / guidance skill** lives in —
50
+ its worth is behavioral, so only a model can judge it. That is **not** "uncovered"
51
+ and **not** free: it's fully testable on the sub. State it that way.
52
+ - **C — Needs a real service** (a real browser / DB / redis / a11y runtime): vigiles
53
+ **composes with a container** here; it does not fake real semantics. Name the
54
+ service and hand off — don't pretend a cheap tier substitutes for it.
55
+
56
+ So a prose-skill library is roughly **~100% testable (some free, most on your sub),
57
+ ~0% needs-a-container** — not "poorly covered." An accessibility/browser plugin is
58
+ the worst case, with a large bucket C. When you report coverage, give **two
59
+ numbers**: "% testable at all (free + sub)" vs "% that needs a container", and say
60
+ which surfaces are free vs sub-priced. The model-gated half is the **point** of the
61
+ eval pillar (affordable on the sub), not a gap — and testing a prose skill's
62
+ _behavior_ requires a real model for **everyone** (promptfoo, the SDKs, all of it);
63
+ vigiles just does it on your subscription instead of metered API.
64
+
33
65
  ## Step 1 — Ensure vigiles is installed
34
66
 
35
67
  Check whether `vigiles` is a dependency (`package.json`), and install it as a
@@ -103,8 +135,29 @@ assertHookFired(r, "SessionStart");
103
135
  assertRequestContains(r, "expected injected text"); // did it actually land?
104
136
  ```
105
137
 
106
- **Eval (`runEval`)** — A/B the change on vs off across real-model trials, then
107
- gate on significance, not eyeballing:
138
+ **Eval — absolute (`measure` + `judged`)** — testing _one_ skill, the usual case:
139
+ score its output directly against a rubric. No on/off baseline this is the
140
+ "is it any good?" oracle (what promptfoo/DeepEval lead with), and the right
141
+ default when there's nothing to compare against:
142
+
143
+ ```ts
144
+ import { measure, judged, skill, assertRates } from "vigiles/testing";
145
+
146
+ const report = await measure({
147
+ pluginDir: "./",
148
+ task: "…a task the skill should handle…",
149
+ checks: [
150
+ skill("my-plugin:my-skill"), // it fired
151
+ judged("the answer correctly does X and avoids Y"), // …and the output is good
152
+ ],
153
+ trials: 6,
154
+ });
155
+ assertRates(report, { min: 0.8 }); // each check passes ≥ 80% of trials
156
+ ```
157
+
158
+ **Eval — relative (`runEval` + `assertSignificant`)** — when the question is
159
+ _lift over no-skill_ (regression, or proving a change isn't noise): A/B the
160
+ change on vs off and gate on significance, not eyeballing:
108
161
 
109
162
  ```ts
110
163
  import { runEval, assertSignificant } from "vigiles/testing";