vigiles 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run-hook.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseHookOutput = parseHookOutput;
4
4
  exports.decideHook = decideHook;
5
+ exports.runHookWith = runHookWith;
5
6
  exports.runHook = runHook;
6
7
  /**
7
8
  * vigiles — the *unit* tier for Claude Code hooks.
@@ -33,6 +34,10 @@ exports.runHook = runHook;
33
34
  * logic here, then assert it fires in the assembled machine there.
34
35
  */
35
36
  const node_child_process_1 = require("node:child_process");
37
+ const node_fs_1 = require("node:fs");
38
+ const node_os_1 = require("node:os");
39
+ const node_path_1 = require("node:path");
40
+ const sandbox_js_1 = require("./sandbox.js");
36
41
  /** Parse stdout as a hook JSON decision (pure, testable without a process). */
37
42
  function parseHookOutput(stdout) {
38
43
  const s = stdout.trim();
@@ -56,12 +61,50 @@ function decideHook(exitCode, json) {
56
61
  return { blocked, decision };
57
62
  }
58
63
  /**
59
- * Run a hook command, piping `input` as JSON to its stdin, and report the exit
60
- * code + parsed decision. Synchronous (so it can be used inside an eval's
61
- * `measure` too). `command` is run through a shell, so the same command string a
62
- * plugin ships (with args / env refs) works verbatim.
64
+ * The hook-run orchestration with injectable spawn seams: pick direct vs.
65
+ * confined via the safe-by-default policy (`decideSandbox`), then parse the exit
66
+ * code + stdout into a normalized decision. Exported so all three branches
67
+ * (direct / sandbox / refuse) are unit-tested with fake spawners no real
68
+ * bwrap. `runHook` is this with the real seams.
63
69
  */
64
- function runHook(command, input, opts = {}) {
70
+ function runHookWith(command, input, opts, deps) {
71
+ // Confinement follows provenance: a trusted hook (the default) runs directly;
72
+ // marking a hook untrusted defaults it to "auto" (confine-or-refuse), so
73
+ // foreign code is never run unconfined by accident. An explicit `sandbox`
74
+ // overrides the default either way. The trust fed to decideSandbox stays
75
+ // `false` at this tier — a raw command has no provenance, so an explicit
76
+ // "auto"/"strict" here is always a request to *confine*, not "trusted→direct".
77
+ // recordEgress needs the netns recorder, so it forces confinement too.
78
+ const mode = opts.sandbox ??
79
+ (opts.trusted === false || opts.recordEgress ? "auto" : false);
80
+ const decision = (0, sandbox_js_1.decideSandbox)({
81
+ trusted: false,
82
+ mode,
83
+ available: deps.available,
84
+ });
85
+ if (decision.action === "throw")
86
+ throw new Error(decision.reason);
87
+ const res = decision.action === "sandbox"
88
+ ? deps.sandboxed(command, input, opts)
89
+ : deps.direct(command, input, opts);
90
+ const exitCode = res.status ?? (res.signal ? 1 : 0);
91
+ const stdout = res.stdout ?? "";
92
+ const stderr = res.stderr ?? "";
93
+ const json = parseHookOutput(stdout);
94
+ const { blocked, decision: dec } = decideHook(exitCode, json);
95
+ return {
96
+ exitCode,
97
+ stdout,
98
+ stderr,
99
+ json,
100
+ blocked,
101
+ egress: res.egress ?? [],
102
+ filesWritten: res.filesWritten ?? [],
103
+ decision: dec,
104
+ };
105
+ }
106
+ /** Run the hook command directly through a shell (the default, unconfined). */
107
+ function directSpawn(command, input, opts) {
65
108
  const res = (0, node_child_process_1.spawnSync)(command, {
66
109
  shell: true,
67
110
  cwd: opts.cwd,
@@ -70,11 +113,146 @@ function runHook(command, input, opts = {}) {
70
113
  encoding: "utf-8",
71
114
  timeout: opts.timeoutMs ?? 10000,
72
115
  });
73
- const exitCode = res.status ?? (res.signal ? 1 : 0);
74
- const stdout = res.stdout ?? "";
75
- const stderr = res.stderr ?? "";
76
- const json = parseHookOutput(stdout);
77
- const { blocked, decision } = decideHook(exitCode, json);
78
- return { exitCode, stdout, stderr, json, blocked, decision };
116
+ return {
117
+ status: res.status,
118
+ signal: res.signal,
119
+ stdout: res.stdout ?? "",
120
+ stderr: res.stderr ?? "",
121
+ };
122
+ }
123
+ /* v8 ignore start -- spawns real bwrap; exercised by the bwrap-gated integration
124
+ test (skipped without bwrap). The decision logic is runHookWith (unit-tested
125
+ with fakes); the confinement argv is bwrapArgs/setenvArgs and the egress log
126
+ parse is parseEgressLog (all unit-tested). */
127
+ // When recordEgress is on: co-launch the recorder on loopback, point HTTP(S)_PROXY
128
+ // at it, run the hook, then stop it. Paths come in via env (no shell escaping).
129
+ const EGRESS_WRAPPER = [
130
+ 'node "$VIG_EGRESS_ENTRY" "$VIG_EGRESS_LOG" "$VIG_EGRESS_PORT" &',
131
+ "EPID=$!",
132
+ "i=0",
133
+ 'while [ ! -s "$VIG_EGRESS_PORT" ] && [ "$i" -lt 100 ]; do sleep 0.05; i=$((i+1)); done',
134
+ 'export HTTP_PROXY="http://127.0.0.1:$(cat "$VIG_EGRESS_PORT")"',
135
+ 'export HTTPS_PROXY="$HTTP_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTP_PROXY"',
136
+ // Node's fetch (undici) ignores the proxy env unless this is set — without it a
137
+ // hook that uses fetch() (e.g. an update check) would bypass the recorder.
138
+ "export NODE_USE_ENV_PROXY=1",
139
+ 'sh -c "$VIG_HOOK_CMD"',
140
+ "code=$?",
141
+ 'kill "$EPID" 2>/dev/null',
142
+ 'exit "$code"',
143
+ ].join("\n");
144
+ function egressProxyEntry() {
145
+ return ([
146
+ (0, node_path_1.join)(__dirname, "egress-proxy.js"),
147
+ (0, node_path_1.join)(__dirname, "..", "dist", "egress-proxy.js"),
148
+ ].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "egress-proxy.js"));
149
+ }
150
+ /** Map every file under `dir` to a content signature (size:mtime), recursively. */
151
+ function snapshotTree(dir) {
152
+ const out = {};
153
+ const walk = (d, rel) => {
154
+ for (const name of (0, node_fs_1.readdirSync)(d)) {
155
+ const full = (0, node_path_1.join)(d, name);
156
+ const r = rel ? `${rel}/${name}` : name;
157
+ const st = (0, node_fs_1.statSync)(full);
158
+ if (st.isDirectory())
159
+ walk(full, r);
160
+ else
161
+ out[r] = `${String(st.size)}:${String(st.mtimeMs)}`;
162
+ }
163
+ };
164
+ try {
165
+ walk(dir, "");
166
+ }
167
+ catch {
168
+ /* dir removed mid-walk — best effort */
169
+ }
170
+ return out;
171
+ }
172
+ function sandboxedSpawn(command, input, opts) {
173
+ const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-hook-sbx-"));
174
+ const home = (0, node_path_1.join)(ioDir, "home");
175
+ (0, node_fs_1.mkdirSync)(home);
176
+ // The hook's confined writable work dir: the caller's cwd if given, else a
177
+ // dedicated `work/` under the IO dir (kept separate from the egress log/home so
178
+ // those don't pollute the filesWritten diff).
179
+ const work = opts.cwd ?? (0, node_path_1.join)(ioDir, "work");
180
+ (0, node_fs_1.mkdirSync)(work, { recursive: true });
181
+ try {
182
+ const baseArgs = [
183
+ ...(0, sandbox_js_1.bwrapArgs)({
184
+ cwd: work,
185
+ ioDir,
186
+ home,
187
+ path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
188
+ }),
189
+ ...(0, sandbox_js_1.setenvArgs)(opts.env ?? {}),
190
+ ];
191
+ const spawnOpts = {
192
+ cwd: work,
193
+ env: process.env,
194
+ input: JSON.stringify(input),
195
+ encoding: "utf-8",
196
+ timeout: opts.timeoutMs ?? 10000,
197
+ };
198
+ const before = snapshotTree(work);
199
+ let res;
200
+ let egress = [];
201
+ if (opts.recordEgress) {
202
+ const egressLog = (0, node_path_1.join)(ioDir, "egress.ndjson");
203
+ const portFile = (0, node_path_1.join)(ioDir, "egress.port");
204
+ (0, node_fs_1.writeFileSync)(egressLog, "");
205
+ res = (0, node_child_process_1.spawnSync)("bwrap", [
206
+ ...baseArgs,
207
+ "--setenv",
208
+ "VIG_EGRESS_ENTRY",
209
+ egressProxyEntry(),
210
+ "--setenv",
211
+ "VIG_EGRESS_LOG",
212
+ egressLog,
213
+ "--setenv",
214
+ "VIG_EGRESS_PORT",
215
+ portFile,
216
+ "--setenv",
217
+ "VIG_HOOK_CMD",
218
+ command,
219
+ "sh",
220
+ "-c",
221
+ EGRESS_WRAPPER,
222
+ ], spawnOpts);
223
+ egress = (0, sandbox_js_1.parseEgressLog)((0, node_fs_1.existsSync)(egressLog) ? (0, node_fs_1.readFileSync)(egressLog, "utf-8") : "");
224
+ }
225
+ else {
226
+ res = (0, node_child_process_1.spawnSync)("bwrap", [...baseArgs, "sh", "-c", command], spawnOpts);
227
+ }
228
+ return {
229
+ status: res.status,
230
+ signal: res.signal,
231
+ stdout: res.stdout ?? "",
232
+ stderr: res.stderr ?? "",
233
+ egress,
234
+ filesWritten: (0, sandbox_js_1.diffTrees)(before, snapshotTree(work)),
235
+ };
236
+ }
237
+ finally {
238
+ (0, node_fs_1.rmSync)(ioDir, { recursive: true, force: true });
239
+ }
240
+ }
241
+ const REAL_DEPS = {
242
+ available: (0, sandbox_js_1.sandboxAvailable)(),
243
+ direct: directSpawn,
244
+ sandboxed: sandboxedSpawn,
245
+ };
246
+ /* v8 ignore stop */
247
+ /**
248
+ * Run a hook command, piping `input` as JSON to its stdin, and report the exit
249
+ * code + parsed decision. Synchronous (so it can be used inside an eval's
250
+ * `measure` too). `command` is run through a shell, so the same command string a
251
+ * plugin ships (with args / env refs) works verbatim. Mark a hook you didn't
252
+ * write with `trusted: false` and it is confined by default (or pass `sandbox:
253
+ * "auto"` directly) — see {@link RunHookOptions.trusted}.
254
+ */
255
+ function runHook(command, input, opts = {}) {
256
+ return runHookWith(command, input, opts, REAL_DEPS);
79
257
  }
80
258
  //# sourceMappingURL=run-hook.js.map
package/dist/sandbox.d.ts CHANGED
@@ -8,10 +8,16 @@ import { type ModelTurn, type ModelRequest } from "./mock-model.js";
8
8
  */
9
9
  export type SandboxMode = "auto" | "strict" | false;
10
10
  /**
11
- * Whether bubblewrap is available to confine untrusted code. **Linux only** —
12
- * bubblewrap is a Linux tool, so this is always `false` on macOS / Windows,
13
- * where confined execution isn't supported and untrusted code must instead be
14
- * run via `sandbox: false` (trusting the outer container) or skipped.
11
+ * Whether this environment can ACTUALLY confine untrusted code under bubblewrap.
12
+ * **Linux only.** Critically, `bwrap --version` succeeding is NOT enough: many CI
13
+ * runners and hardened hosts ship bubblewrap but disable the **unprivileged user
14
+ * namespaces** it depends on, so a real confined exec fails even though the binary
15
+ * is present. We probe that real capability — a throwaway `bwrap --unshare-all …
16
+ * true` — and cache it, so we never *claim* confinement we can't deliver.
17
+ * `decideSandbox` then correctly refuses untrusted code in such an environment
18
+ * (rather than running it in a "sandbox" that doesn't actually sandbox), and the
19
+ * sandbox-gated tests skip instead of failing. The result is cached because the
20
+ * probe spawns a process and the answer can't change within a run.
15
21
  */
16
22
  export declare function sandboxAvailable(): boolean;
17
23
  /**
@@ -59,8 +65,33 @@ export declare function bwrapArgs(opts: {
59
65
  home: string;
60
66
  path: string;
61
67
  }): string[];
68
+ /**
69
+ * `--setenv K V` pairs to add back specific variables after `--clearenv` — e.g.
70
+ * a hook's configured env (the `GUARD=path` a plugin's command relies on), which
71
+ * `bwrapArgs`' `--clearenv` would otherwise drop. Pure, so it's unit-tested.
72
+ */
73
+ export declare function setenvArgs(env: Record<string, string>): string[];
62
74
  /** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
63
75
  export declare function parseRequestLog(ndjson: string): ModelRequest[];
76
+ /** A network egress attempt a confined hook made — recorded, then blocked. */
77
+ export interface EgressAttempt {
78
+ readonly host: string;
79
+ readonly port: number;
80
+ /** ms epoch when the attempt was recorded. */
81
+ readonly ts: number;
82
+ }
83
+ /**
84
+ * Parse the egress recorder's ndjson log into {@link EgressAttempt}s. Pure, so
85
+ * the record-shape and the malformed-line tolerance are unit-tested without a
86
+ * sandbox. A line missing host/port is skipped (a partially-flushed final line).
87
+ */
88
+ export declare function parseEgressLog(ndjson: string): EgressAttempt[];
89
+ /**
90
+ * The files in `after` that are new or changed vs `before` — i.e. what a confined
91
+ * run wrote to its work dir. Each tree maps a relative path to a content
92
+ * signature (size + mtime). Pure, so the diff is unit-tested without a sandbox.
93
+ */
94
+ export declare function diffTrees(before: Readonly<Record<string, string>>, after: Readonly<Record<string, string>>): string[];
64
95
  /** The raw output of a sandboxed run: exit code, captured stdout, and requests. */
65
96
  export interface SandboxRunOut {
66
97
  readonly code: number;
package/dist/sandbox.js CHANGED
@@ -4,7 +4,10 @@ exports.sandboxAvailable = sandboxAvailable;
4
4
  exports.specTrusted = specTrusted;
5
5
  exports.decideSandbox = decideSandbox;
6
6
  exports.bwrapArgs = bwrapArgs;
7
+ exports.setenvArgs = setenvArgs;
7
8
  exports.parseRequestLog = parseRequestLog;
9
+ exports.parseEgressLog = parseEgressLog;
10
+ exports.diffTrees = diffTrees;
8
11
  exports.runSandboxed = runSandboxed;
9
12
  /**
10
13
  * vigiles — safe-by-default confinement for executing untrusted harness code.
@@ -32,18 +35,35 @@ const node_child_process_1 = require("node:child_process");
32
35
  const node_fs_1 = require("node:fs");
33
36
  const node_os_1 = require("node:os");
34
37
  const node_path_1 = require("node:path");
38
+ let cachedAvailable;
35
39
  /**
36
- * Whether bubblewrap is available to confine untrusted code. **Linux only** —
37
- * bubblewrap is a Linux tool, so this is always `false` on macOS / Windows,
38
- * where confined execution isn't supported and untrusted code must instead be
39
- * run via `sandbox: false` (trusting the outer container) or skipped.
40
+ * Whether this environment can ACTUALLY confine untrusted code under bubblewrap.
41
+ * **Linux only.** Critically, `bwrap --version` succeeding is NOT enough: many CI
42
+ * runners and hardened hosts ship bubblewrap but disable the **unprivileged user
43
+ * namespaces** it depends on, so a real confined exec fails even though the binary
44
+ * is present. We probe that real capability — a throwaway `bwrap --unshare-all …
45
+ * true` — and cache it, so we never *claim* confinement we can't deliver.
46
+ * `decideSandbox` then correctly refuses untrusted code in such an environment
47
+ * (rather than running it in a "sandbox" that doesn't actually sandbox), and the
48
+ * sandbox-gated tests skip instead of failing. The result is cached because the
49
+ * probe spawns a process and the answer can't change within a run.
40
50
  */
41
51
  function sandboxAvailable() {
52
+ if (cachedAvailable === undefined)
53
+ cachedAvailable = probeSandbox();
54
+ return cachedAvailable;
55
+ }
56
+ function probeSandbox() {
42
57
  /* v8 ignore next -- non-Linux has no bwrap; CI/coverage runs on Linux */
43
58
  if (process.platform !== "linux")
44
59
  return false;
45
60
  try {
46
- return (0, node_child_process_1.spawnSync)("bwrap", ["--version"], { stdio: "ignore" }).status === 0;
61
+ // The capability that fails when user namespaces are disabled is the
62
+ // namespace creation itself (`--unshare-all`), so probe exactly that.
63
+ return ((0, node_child_process_1.spawnSync)("bwrap", ["--unshare-all", "--ro-bind", "/", "/", "true"], {
64
+ stdio: "ignore",
65
+ timeout: 10_000,
66
+ }).status === 0);
47
67
  }
48
68
  catch {
49
69
  /* v8 ignore next -- defensive: spawnSync only throws on a fork failure */
@@ -138,6 +158,17 @@ function bwrapArgs(opts) {
138
158
  "--new-session",
139
159
  ];
140
160
  }
161
+ /**
162
+ * `--setenv K V` pairs to add back specific variables after `--clearenv` — e.g.
163
+ * a hook's configured env (the `GUARD=path` a plugin's command relies on), which
164
+ * `bwrapArgs`' `--clearenv` would otherwise drop. Pure, so it's unit-tested.
165
+ */
166
+ function setenvArgs(env) {
167
+ const out = [];
168
+ for (const [k, v] of Object.entries(env))
169
+ out.push("--setenv", k, v);
170
+ return out;
171
+ }
141
172
  /** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
142
173
  function parseRequestLog(ndjson) {
143
174
  const out = [];
@@ -153,6 +184,41 @@ function parseRequestLog(ndjson) {
153
184
  }
154
185
  return out;
155
186
  }
187
+ /**
188
+ * Parse the egress recorder's ndjson log into {@link EgressAttempt}s. Pure, so
189
+ * the record-shape and the malformed-line tolerance are unit-tested without a
190
+ * sandbox. A line missing host/port is skipped (a partially-flushed final line).
191
+ */
192
+ function parseEgressLog(ndjson) {
193
+ const out = [];
194
+ for (const line of ndjson.split("\n")) {
195
+ if (!line.trim())
196
+ continue;
197
+ try {
198
+ const o = JSON.parse(line);
199
+ if (typeof o.host === "string" && typeof o.port === "number") {
200
+ out.push({ host: o.host, port: o.port, ts: Number(o.ts) || 0 });
201
+ }
202
+ }
203
+ catch {
204
+ /* a partially-written final line — skip */
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * The files in `after` that are new or changed vs `before` — i.e. what a confined
211
+ * run wrote to its work dir. Each tree maps a relative path to a content
212
+ * signature (size + mtime). Pure, so the diff is unit-tested without a sandbox.
213
+ */
214
+ function diffTrees(before, after) {
215
+ const out = [];
216
+ for (const [path, sig] of Object.entries(after)) {
217
+ if (before[path] !== sig)
218
+ out.push(path);
219
+ }
220
+ return out.sort();
221
+ }
156
222
  /**
157
223
  * Co-launch the scripted mock and `claude` inside ONE bubblewrap network
158
224
  * namespace: the mock serves on the sandbox's loopback (reachable), egress is
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `vigiles/testing` — Pillar 2 entry point: the **harness-testing** API. Re-exports
3
+ * the three tiers — `runHook` (unit), `runHarnessTest` (deterministic), `runEval`
4
+ * (eval) — plus the runner-agnostic predicates/assertions. Kept deliberately
5
+ * separate from `vigiles/claude-code` so this surface can stay harness-agnostic as
6
+ * more harnesses are added. Granular paths (`vigiles/run-hook`, etc.) still work.
7
+ */
8
+ export * from "./run-hook.js";
9
+ export * from "./harness-test.js";
10
+ export * from "./eval.js";
11
+ export * from "./harness-assert.js";
12
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * `vigiles/testing` — Pillar 2 entry point: the **harness-testing** API. Re-exports
19
+ * the three tiers — `runHook` (unit), `runHarnessTest` (deterministic), `runEval`
20
+ * (eval) — plus the runner-agnostic predicates/assertions. Kept deliberately
21
+ * separate from `vigiles/claude-code` so this surface can stay harness-agnostic as
22
+ * more harnesses are added. Granular paths (`vigiles/run-hook`, etc.) still work.
23
+ */
24
+ __exportStar(require("./run-hook.js"), exports);
25
+ __exportStar(require("./harness-test.js"), exports);
26
+ __exportStar(require("./eval.js"), exports);
27
+ __exportStar(require("./harness-assert.js"), exports);
28
+ //# sourceMappingURL=testing.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "2.5.0",
3
+ "version": "2.6.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,6 +9,9 @@
9
9
  "types": "./dist/spec.d.ts",
10
10
  "exports": {
11
11
  ".": "./dist/spec.js",
12
+ "./linting": "./dist/linting.js",
13
+ "./testing": "./dist/testing.js",
14
+ "./claude-code": "./dist/claude-code.js",
12
15
  "./spec": "./dist/spec.js",
13
16
  "./compile": "./dist/compile.js",
14
17
  "./linters": "./dist/linters.js",
@@ -51,7 +54,8 @@
51
54
  "test:eval": "npm run build && node dist/cli.js eval",
52
55
  "test:vitest": "npm run build && vitest run --project runners",
53
56
  "test:jest": "npm run build && jest",
54
- "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json"
57
+ "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json",
58
+ "demo:plugin": "npm run build && node examples/plugin-test-demo.mjs"
55
59
  },
56
60
  "devDependencies": {
57
61
  "@eslint/js": "^10.0.1",