vigiles 35.0.0 → 37.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.
@@ -1,35 +1,30 @@
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;
1
+ import { type StubCall, type ToolStub } from "./core/stub-rules.js";
2
+ /** Where a run's stubs live. `binDir` goes first on the run's PATH. */
3
+ export interface StubDir {
4
+ readonly root: string;
5
+ readonly binDir: string;
15
6
  }
7
+ /** How a shim reaches the stub runtime: this node, and this vigiles' `cli.js`. */
8
+ export interface StubLauncher {
9
+ readonly node: string;
10
+ readonly cli: string;
11
+ }
12
+ /** The shim for one stub. Pure. */
13
+ export declare function stubShim(launcher: StubLauncher, root: string, name: string): string;
16
14
  /**
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.
15
+ * Write a fresh stub root for `stubs`: a shim and a rules file per stub. The
16
+ * stubs must already be parsed (the spec boundary did it). Remove it with
17
+ * {@link removeStubDir}.
26
18
  */
27
- export declare function writeToolStubs(binDir: string, stubs: readonly ToolStub[]): void;
19
+ export declare function writeStubDir(stubs: readonly ToolStub[], launcher: StubLauncher): StubDir;
20
+ /** Every invocation the run's stubs recorded, in order. Empty when none was called. */
21
+ export declare function readStubCalls(dir: StubDir): readonly StubCall[];
28
22
  /**
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).
23
+ * Forget every call recorded so far. The log is the stubs' only state (an
24
+ * `inOrder` rule counts its prior answers in it), so a cleared log is a stub
25
+ * that has never been called. Safe when no call was logged yet.
33
26
  */
34
- export declare function stubBinDir(stubs: readonly ToolStub[], parentDir: string): string;
27
+ export declare function resetStubCalls(dir: StubDir): void;
28
+ /** Remove a stub root. Safe on one already gone. */
29
+ export declare function removeStubDir(dir: StubDir): void;
35
30
  //# sourceMappingURL=tool-stub.d.ts.map
package/dist/tool-stub.js CHANGED
@@ -1,92 +1,81 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.renderToolStub = renderToolStub;
4
- exports.writeToolStubs = writeToolStubs;
5
- exports.stubBinDir = stubBinDir;
3
+ exports.stubShim = stubShim;
4
+ exports.writeStubDir = writeStubDir;
5
+ exports.readStubCalls = readStubCalls;
6
+ exports.resetStubCalls = resetStubCalls;
7
+ exports.removeStubDir = removeStubDir;
6
8
  /**
7
- * vigiles — **tool stubs on PATH** (rung R2 of the eval coverage model).
9
+ * vigiles — tool stubs on PATH: the EFFECTS half (rung R2 of the eval coverage
10
+ * model). The rules, the decision and the log format are pure, in
11
+ * `core/stub-rules.ts`; this file writes them to disk and reads the log back.
8
12
  *
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).
13
+ * Layout of one stub root — a temp dir BESIDE the run's working dir, never in
14
+ * it, so the agent does not find vigiles plumbing in its project (models did
15
+ * `ls`/`cat` a stub dir that lived inside the work dir):
15
16
  *
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.
17
+ * <root>/bin/<name> a two-line sh shim, first on the run's PATH
18
+ * <root>/rules/<name>.json the encoded rules (`encodeStub`)
19
+ * <root>/calls.jsonl one line per invocation, every stub of the run
20
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).
21
+ * The shim execs `node <vigiles cli.js> hook-runtime stub <root> <name> "$@"`,
22
+ * a fast path in the dispatcher that loads only the pure decision — the same
23
+ * shape as the intercept hook, which also calls back into the CLI by absolute
24
+ * path because `npx vigiles` does not resolve from a throwaway dir.
25
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.
26
+ * The canned answers are author/recorded fixtures, NEVER model-synthesized: a
27
+ * synthesized `gh` output looks plausible and diverges from the real tool.
29
28
  */
30
29
  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");
30
+ const stub_rules_js_1 = require("./core/stub-rules.js");
31
+ const tmp_root_js_1 = require("./core/tmp-root.js");
32
+ /** A POSIX single-quoted word: the only quoting `sh` never re-reads. */
33
+ const shQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
34
+ /** The shim for one stub. Pure. */
35
+ function stubShim(launcher, root, name) {
36
+ const words = [
37
+ launcher.node,
38
+ launcher.cli,
39
+ "hook-runtime",
40
+ "stub",
41
+ root,
42
+ name,
43
+ ];
44
+ return `#!/bin/sh\nexec ${words.map(shQuote).join(" ")} "$@"\n`;
47
45
  }
48
46
  /**
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.
47
+ * Write a fresh stub root for `stubs`: a shim and a rules file per stub. The
48
+ * stubs must already be parsed (the spec boundary did it). Remove it with
49
+ * {@link removeStubDir}.
52
50
  */
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";
51
+ function writeStubDir(stubs, launcher) {
52
+ const root = (0, tmp_root_js_1.makeTmpDir)("stubs");
53
+ const { binDir, rulesDir } = (0, stub_rules_js_1.stubPaths)(root);
54
+ (0, node_fs_1.mkdirSync)(binDir, { recursive: true });
55
+ (0, node_fs_1.mkdirSync)(rulesDir, { recursive: true });
56
+ stubs.forEach((stub) => {
57
+ const paths = (0, stub_rules_js_1.stubPaths)(root, stub.name);
58
+ (0, node_fs_1.writeFileSync)(paths.rules, (0, stub_rules_js_1.encodeStub)(stub), "utf8");
59
+ (0, node_fs_1.writeFileSync)(paths.bin, stubShim(launcher, root, stub.name), "utf8");
60
+ (0, node_fs_1.chmodSync)(paths.bin, 0o755);
61
+ });
62
+ return { root, binDir };
67
63
  }
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
- }
64
+ /** Every invocation the run's stubs recorded, in order. Empty when none was called. */
65
+ function readStubCalls(dir) {
66
+ const { log } = (0, stub_rules_js_1.stubPaths)(dir.root);
67
+ return (0, node_fs_1.existsSync)(log) ? (0, stub_rules_js_1.parseStubLog)((0, node_fs_1.readFileSync)(log, "utf8")) : [];
79
68
  }
80
69
  /**
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).
70
+ * Forget every call recorded so far. The log is the stubs' only state (an
71
+ * `inOrder` rule counts its prior answers in it), so a cleared log is a stub
72
+ * that has never been called. Safe when no call was logged yet.
85
73
  */
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;
74
+ function resetStubCalls(dir) {
75
+ (0, node_fs_1.rmSync)((0, stub_rules_js_1.stubPaths)(dir.root).log, { force: true });
76
+ }
77
+ /** Remove a stub root. Safe on one already gone. */
78
+ function removeStubDir(dir) {
79
+ (0, node_fs_1.rmSync)(dir.root, { recursive: true, force: true });
91
80
  }
92
81
  //# sourceMappingURL=tool-stub.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "35.0.0",
3
+ "version": "37.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -10,18 +10,19 @@ questions are keyed on the **observation** instead — "what did this skill
10
10
  actually do?" — and they have answers already. Reach for these before building
11
11
  anything; every one of them ships today.
12
12
 
13
- | The question you're actually asking | Use |
14
- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
15
- | Which tools did it call, and with what arguments? | `trace.toolCalls` · `tool` / `toolWith` checks · `parseToolCalls` (`vigiles`) |
16
- | Did it call a tool it must not? | `notTool(name)` |
17
- | Did it call **only** tools from a known set? | `onlyTools([...])` — the white-list, symmetric to `assertWroteOnly` |
18
- | Did it stay inside the `allowed-tools` its own frontmatter declares? | `skillContract(dir).surface` — builds that check FROM the declaration |
19
- | What files did the run write? | `filesWritten` · `wrote(path)` / `didNotWrite(path)` · `r.file(path)` |
20
- | Did it write **only** where it was supposed to? | `assertWroteOnly([...])` / `assertNoWrite()` — needs `{ sandbox: "auto" }` |
21
- | Run a tool call but **don't let it execute** — capture the args instead | the `interceptTools` option on `measure` / `runEval` (a `ToolIntercept[]`) |
22
- | Did a subagent do it, and which one? | `subagent(name, [...])` · `SubagentTrace` |
23
- | Was it an MCP tool? | `mcp(server, toolName)` |
24
- | Assert the whole effect boundary deterministically | `assertChecks` + the checks above (see `examples/harness/effect-boundary.harness.mjs`) |
13
+ | The question you're actually asking | Use |
14
+ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | Which tools did it call, and with what arguments? | `trace.toolCalls` · `tool` / `toolWith` checks · `parseToolCalls` (`vigiles`) |
16
+ | Did it call a tool it must not? | `notTool(name)` |
17
+ | Did it call **only** tools from a known set? | `onlyTools([...])` — the white-list, symmetric to `assertWroteOnly` |
18
+ | Did it stay inside the `allowed-tools` its own frontmatter declares? | `skillContract(dir).surface` — builds that check FROM the declaration |
19
+ | What files did the run write? | `filesWritten` · `wrote(path)` / `didNotWrite(path)` · `r.file(path)` |
20
+ | Did it write **only** where it was supposed to? | `assertWroteOnly([...])` / `assertNoWrite()` — needs `{ sandbox: "auto" }` |
21
+ | Run a tool call but **don't let it execute** — capture the args instead | the `interceptTools` option on `measure` / `runEval` (a `ToolIntercept[]`) |
22
+ | Let a CLI the agent (or its script) calls **answer** — `gh`, `psql` | `stubs: [experimental_stub(name, rules)]` on `runEval`; read `ctx.stubCalls`, check with `experimental_stub.called(...)` — an argv no rule answers fails the eval |
23
+ | Did a subagent do it, and which one? | `subagent(name, [...])` · `SubagentTrace` |
24
+ | Was it an MCP tool? | `mcp(server, toolName)` |
25
+ | Assert the whole effect boundary deterministically | `assertChecks` + the checks above (see `examples/harness/effect-boundary.harness.mjs`) |
25
26
 
26
27
  `interceptTools` is the one worth knowing about, because it is not obvious it
27
28
  exists: it denies a tool its **real execution** via an auto-wired `PreToolUse`
@@ -90,6 +90,7 @@ import { defineEval, assertSignificant } from "vigiles";
90
90
 
91
91
  export default defineEval({
92
92
  runEval: {
93
+ env: { kind: "ephemeral" }, // required: a throwaway HOME, or { kind: "inherit", reason }
93
94
  arms: { off: {}, on: { pluginDir: "./" } },
94
95
  task: "…a task the harness change should affect…",
95
96
  measure: (ctx) => ({ ok: /* a bare predicate over the trace */ true }),