vigiles 2.3.0 → 2.5.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,12 +14,38 @@ export interface TurnInfo {
14
14
  readonly stream: boolean;
15
15
  readonly hasToolResult: boolean;
16
16
  }
17
+ /**
18
+ * One `/v1/messages` request the mock received, flattened to text for
19
+ * assertions. This is the seam that lets a harness test prove what reached the
20
+ * model — a SessionStart hook's injected `additionalContext`, or a slash
21
+ * command's expansion — not just that a hook fired.
22
+ */
23
+ export interface ModelRequest {
24
+ /** The system prompt, flattened to text (string or text-block array). */
25
+ readonly system: string;
26
+ /** The conversation messages, each flattened to `{ role, text }`. */
27
+ readonly messages: readonly {
28
+ readonly role: string;
29
+ readonly text: string;
30
+ }[];
31
+ }
17
32
  export interface MockHandle {
18
33
  readonly url: string;
19
34
  close(): void;
20
35
  /** Number of model turns served so far. */
21
36
  readonly count: number;
37
+ /** Every `/v1/messages` request the mock received, in order. */
38
+ readonly requests: readonly ModelRequest[];
22
39
  }
40
+ /**
41
+ * Extract a {@link ModelRequest} from a request body — the `system` prompt and
42
+ * `messages`, each flattened to text. Pure and exported so the capture logic is
43
+ * testable without the HTTP server.
44
+ */
45
+ export declare function extractRequest(body: {
46
+ system?: unknown;
47
+ messages?: unknown;
48
+ }): ModelRequest;
23
49
  /**
24
50
  * Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
25
51
  * next turn (the last turn repeats if the client asks for more). Resolves to a
@@ -27,5 +53,8 @@ export interface MockHandle {
27
53
  */
28
54
  export declare function startMock(script: readonly ModelTurn[], opts?: {
29
55
  onTurn?: (info: TurnInfo) => void;
56
+ /** Called with each `/v1/messages` request as it arrives — used by the
57
+ * in-sandbox mock entry to stream requests to a file for the parent. */
58
+ onRequest?: (req: ModelRequest) => void;
30
59
  }): Promise<MockHandle>;
31
60
  //# sourceMappingURL=mock-model.d.ts.map
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.scriptModel = scriptModel;
7
+ exports.extractRequest = extractRequest;
7
8
  exports.startMock = startMock;
8
9
  /**
9
10
  * vigiles — a scripted, deterministic Anthropic Messages API mock.
@@ -127,6 +128,38 @@ function jsonTurn(res, turn, model) {
127
128
  usage: { input_tokens: 10, output_tokens: 5 },
128
129
  }));
129
130
  }
131
+ /** Flatten Anthropic content (string, or an array of text/other blocks) to text. */
132
+ function flattenContent(content) {
133
+ if (typeof content === "string")
134
+ return content;
135
+ if (!Array.isArray(content))
136
+ return "";
137
+ return content
138
+ .map((b) => {
139
+ if (typeof b === "string")
140
+ return b;
141
+ const t = b.text;
142
+ return typeof t === "string" ? t : "";
143
+ })
144
+ .join("");
145
+ }
146
+ /**
147
+ * Extract a {@link ModelRequest} from a request body — the `system` prompt and
148
+ * `messages`, each flattened to text. Pure and exported so the capture logic is
149
+ * testable without the HTTP server.
150
+ */
151
+ function extractRequest(body) {
152
+ const messages = Array.isArray(body.messages)
153
+ ? body.messages.map((m) => {
154
+ const msg = m;
155
+ return {
156
+ role: typeof msg.role === "string" ? msg.role : "",
157
+ text: flattenContent(msg.content),
158
+ };
159
+ })
160
+ : [];
161
+ return { system: flattenContent(body.system), messages };
162
+ }
130
163
  /**
131
164
  * Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
132
165
  * next turn (the last turn repeats if the client asks for more). Resolves to a
@@ -134,6 +167,7 @@ function jsonTurn(res, turn, model) {
134
167
  */
135
168
  function startMock(script, opts = {}) {
136
169
  let i = 0;
170
+ const requests = [];
137
171
  const server = node_http_1.default.createServer((req, res) => {
138
172
  let body = "";
139
173
  req.on("data", (c) => (body += c));
@@ -158,6 +192,9 @@ function startMock(script, opts = {}) {
158
192
  res.end(JSON.stringify({ input_tokens: 10 }));
159
193
  return;
160
194
  }
195
+ const request = extractRequest(reqBody);
196
+ requests.push(request);
197
+ opts.onRequest?.(request);
161
198
  const last = JSON.stringify(reqBody.messages?.at(-1)?.content ?? "");
162
199
  opts.onTurn?.({
163
200
  n: i,
@@ -182,6 +219,9 @@ function startMock(script, opts = {}) {
182
219
  get count() {
183
220
  return i;
184
221
  },
222
+ get requests() {
223
+ return requests;
224
+ },
185
225
  });
186
226
  });
187
227
  });
@@ -26,16 +26,19 @@ exports.resolveHarness = resolveHarness;
26
26
  const node_fs_1 = require("node:fs");
27
27
  const node_path_1 = require("node:path");
28
28
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
29
- /** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
30
- function readHooksFile(path) {
29
+ /** Parse a JSON file, or null on any error (missing / malformed). */
30
+ function safeReadJson(path) {
31
31
  try {
32
- return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"))
33
- .hooks;
32
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
34
33
  }
35
34
  catch {
36
- return undefined;
35
+ return null;
37
36
  }
38
37
  }
38
+ /** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
39
+ function readHooksFile(path) {
40
+ return safeReadJson(path)?.hooks;
41
+ }
39
42
  /**
40
43
  * Read the hooks block, handling the real-world plugin layouts:
41
44
  * 1. inline `hooks` object in .claude-plugin/plugin.json,
@@ -44,9 +47,10 @@ function readHooksFile(path) {
44
47
  * 4. a plain repo's `.claude/settings.json`.
45
48
  */
46
49
  function readHooks(root) {
47
- const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
48
- if ((0, node_fs_1.existsSync)(manifestPath)) {
49
- const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
50
+ // A malformed plugin.json must not crash the loader — fall through to the
51
+ // other layouts (safeReadJson returns null on a parse error).
52
+ const m = safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"));
53
+ if (m) {
50
54
  if (typeof m.hooks === "string")
51
55
  return readHooksFile((0, node_path_1.join)(root, m.hooks));
52
56
  if (m.hooks !== undefined)
@@ -134,6 +138,12 @@ function pluginWarnings(root, counts, hooks, files) {
134
138
  if (hasMcp(root)) {
135
139
  warnings.push(`plugin declares MCP server(s) (mcpServers / .mcp.json) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
136
140
  }
141
+ const dangling = danglingRefs(root);
142
+ if (dangling.length) {
143
+ const shown = dangling.slice(0, 5).join(", ");
144
+ const more = dangling.length > 5 ? `, … (+${String(dangling.length - 5)})` : "";
145
+ warnings.push(`plugin references ${String(dangling.length)} intra-plugin file(s) that don't exist (broken path / partial vendor): ${shown}${more}`);
146
+ }
137
147
  if (!hooks && Object.keys(files).length === 0) {
138
148
  warnings.push(`nothing was loaded (no hooks, CLAUDE.md, skills, agents, or commands) — the deterministic harness would run an effectively empty machine.`);
139
149
  }
@@ -143,16 +153,40 @@ function pluginWarnings(root, counts, hooks, files) {
143
153
  function hasMcp(root) {
144
154
  if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, ".mcp.json")))
145
155
  return true;
146
- const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
147
- if (!(0, node_fs_1.existsSync)(manifestPath))
148
- return false;
149
- try {
150
- const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
151
- return m.mcpServers !== undefined;
152
- }
153
- catch {
154
- return false;
156
+ return (safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"))?.mcpServers !==
157
+ undefined);
158
+ }
159
+ // A plugin-relative path reference to a file under a standard surface dir, with a
160
+ // known extension e.g. a hook script that `cat`s `skills/using-superpowers/SKILL.md`.
161
+ const INTRA_REF_RE = /(?:skills|hooks|commands|agents)\/[A-Za-z0-9._/-]+\.(?:md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json)/g;
162
+ /**
163
+ * Intra-plugin file references that don't resolve — the partial-vendor / broken-
164
+ * path class (e.g. obra/superpowers' `SessionStart` reads
165
+ * `skills/using-superpowers/SKILL.md`, which a sliced vendor snapshot omits). We
166
+ * scan the plugin's own text files under the surface dirs (hooks scripts
167
+ * included — those aren't materialized into `files`) for root-relative path refs
168
+ * and report the ones missing on disk. A static check that would have caught a
169
+ * bug the dogfood hit twice. Best-effort: a warning, not an error.
170
+ */
171
+ function danglingRefs(root) {
172
+ const missing = new Set();
173
+ const seen = new Set();
174
+ for (const surface of ["hooks", "skills", "agents", "commands"]) {
175
+ const dir = (0, node_path_1.join)(root, surface);
176
+ if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
177
+ continue;
178
+ for (const content of Object.values(readTree(dir, root))) {
179
+ for (const m of content.matchAll(INTRA_REF_RE)) {
180
+ const ref = m[0];
181
+ if (seen.has(ref))
182
+ continue;
183
+ seen.add(ref);
184
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, ref)))
185
+ missing.add(ref);
186
+ }
187
+ }
155
188
  }
189
+ return [...missing];
156
190
  }
157
191
  /**
158
192
  * Merge a loaded plugin's settings with inline settings. Inline wins; when both
@@ -0,0 +1,76 @@
1
+ import { type ModelTurn, type ModelRequest } from "./mock-model.js";
2
+ /**
3
+ * How to treat code execution. `"auto"` (default) is safe-by-default: trusted
4
+ * code runs directly, untrusted code is sandboxed if possible and otherwise
5
+ * refuses. `false` is the dangerous opt-out — run unconfined (you audited it, or
6
+ * you trust the outer container). `"strict"` forces confinement even for trusted
7
+ * code and throws if no sandbox is available.
8
+ */
9
+ export type SandboxMode = "auto" | "strict" | false;
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.
15
+ */
16
+ export declare function sandboxAvailable(): boolean;
17
+ /**
18
+ * Is this spec's executed code trusted? Inline `settings`/`files` you authored
19
+ * are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
20
+ * and is NOT — committing it to your repo is the same trust decision as a
21
+ * dependency, so the trust boundary follows provenance: foreign = confined.
22
+ */
23
+ export declare function specTrusted(spec: {
24
+ plugin?: string;
25
+ pluginDir?: string;
26
+ }): boolean;
27
+ /** The chosen action for a run: execute directly, confine it, or refuse. */
28
+ export type SandboxDecision = {
29
+ readonly action: "direct";
30
+ } | {
31
+ readonly action: "sandbox";
32
+ } | {
33
+ readonly action: "throw";
34
+ readonly reason: string;
35
+ };
36
+ /**
37
+ * The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
38
+ * the caller explicitly opted out (`mode: false`). This is the whole security
39
+ * contract, isolated as a pure function so it is exhaustively unit-tested.
40
+ */
41
+ export declare function decideSandbox(opts: {
42
+ trusted: boolean;
43
+ mode: SandboxMode;
44
+ available: boolean;
45
+ }): SandboxDecision;
46
+ /**
47
+ * The bubblewrap confinement argv (everything before the command): a fresh
48
+ * network namespace (`--unshare-all`, loopback-only, no egress), a read-only
49
+ * system, writable mounts limited to the work dir, the IO dir, and a fresh empty
50
+ * HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
51
+ * no host credentials/config leak in), and a **cleared environment** —
52
+ * `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
53
+ * HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
54
+ * Pure, so the confinement shape is asserted in a unit test.
55
+ */
56
+ export declare function bwrapArgs(opts: {
57
+ cwd: string;
58
+ ioDir: string;
59
+ home: string;
60
+ path: string;
61
+ }): string[];
62
+ /** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
63
+ export declare function parseRequestLog(ndjson: string): ModelRequest[];
64
+ /** The raw output of a sandboxed run: exit code, captured stdout, and requests. */
65
+ export interface SandboxRunOut {
66
+ readonly code: number;
67
+ readonly stdout: string;
68
+ readonly requests: readonly ModelRequest[];
69
+ }
70
+ export declare function runSandboxed(opts: {
71
+ cwd: string;
72
+ claudeArgs: readonly string[];
73
+ script: readonly ModelTurn[];
74
+ timeoutMs: number;
75
+ }): Promise<SandboxRunOut>;
76
+ //# sourceMappingURL=sandbox.d.ts.map
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sandboxAvailable = sandboxAvailable;
4
+ exports.specTrusted = specTrusted;
5
+ exports.decideSandbox = decideSandbox;
6
+ exports.bwrapArgs = bwrapArgs;
7
+ exports.parseRequestLog = parseRequestLog;
8
+ exports.runSandboxed = runSandboxed;
9
+ /**
10
+ * vigiles — safe-by-default confinement for executing untrusted harness code.
11
+ *
12
+ * `runHarnessTest` runs the real `claude` CLI, which runs the real hooks of
13
+ * whatever plugin you load. For code YOU authored (inline `settings`/`files`)
14
+ * that's fine — trust is implicit. But pointing it at someone else's `plugin` /
15
+ * `pluginDir` executes THEIR hooks with your privileges. This module makes that
16
+ * safe by default: untrusted code is confined under bubblewrap, or — if no
17
+ * sandbox is available — the run refuses rather than executing unconfined.
18
+ *
19
+ * Confinement (proven on bwrap 0.9): `--unshare-all` gives a fresh network
20
+ * namespace whose loopback is auto-up but has NO external route — so the
21
+ * scripted mock, co-launched INSIDE the namespace, is reachable over 127.0.0.1
22
+ * while a malicious hook cannot phone home. The filesystem is `--ro-bind`
23
+ * read-only except the throwaway work dir, a fresh empty `$HOME`, and an IO dir
24
+ * used to hand the script in and stream captured requests back out.
25
+ *
26
+ * The policy (`decideSandbox`), trust test (`specTrusted`), and bwrap argv
27
+ * (`bwrapArgs`) are pure and unit-tested; the executor (`runSandboxed`) needs a
28
+ * real bwrap and is covered by the integration test, which skips where bwrap is
29
+ * absent — the same pattern as the real-`claude` paths.
30
+ */
31
+ const node_child_process_1 = require("node:child_process");
32
+ const node_fs_1 = require("node:fs");
33
+ const node_os_1 = require("node:os");
34
+ const node_path_1 = require("node:path");
35
+ /**
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
+ */
41
+ function sandboxAvailable() {
42
+ /* v8 ignore next -- non-Linux has no bwrap; CI/coverage runs on Linux */
43
+ if (process.platform !== "linux")
44
+ return false;
45
+ try {
46
+ return (0, node_child_process_1.spawnSync)("bwrap", ["--version"], { stdio: "ignore" }).status === 0;
47
+ }
48
+ catch {
49
+ /* v8 ignore next -- defensive: spawnSync only throws on a fork failure */
50
+ return false;
51
+ }
52
+ }
53
+ /**
54
+ * Is this spec's executed code trusted? Inline `settings`/`files` you authored
55
+ * are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
56
+ * and is NOT — committing it to your repo is the same trust decision as a
57
+ * dependency, so the trust boundary follows provenance: foreign = confined.
58
+ */
59
+ function specTrusted(spec) {
60
+ return spec.plugin === undefined && spec.pluginDir === undefined;
61
+ }
62
+ /**
63
+ * The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
64
+ * the caller explicitly opted out (`mode: false`). This is the whole security
65
+ * contract, isolated as a pure function so it is exhaustively unit-tested.
66
+ */
67
+ function decideSandbox(opts) {
68
+ // Explicit dangerous opt-out: run unconfined, trusted or not.
69
+ if (opts.mode === false)
70
+ return { action: "direct" };
71
+ // Force confinement regardless of trust; refuse if we can't.
72
+ if (opts.mode === "strict") {
73
+ return opts.available
74
+ ? { action: "sandbox" }
75
+ : {
76
+ action: "throw",
77
+ reason: "sandbox: 'strict' requires Linux + bubblewrap (bwrap), which was not available",
78
+ };
79
+ }
80
+ // auto: trusted code runs directly; untrusted must be confined or refused.
81
+ if (opts.trusted)
82
+ return { action: "direct" };
83
+ return opts.available
84
+ ? { action: "sandbox" }
85
+ : {
86
+ action: "throw",
87
+ reason: "refusing to execute an untrusted plugin's hooks without a sandbox: " +
88
+ "the sandbox needs Linux + bubblewrap (bwrap) — install it to run " +
89
+ "confined, or pass sandbox: false to run unconfined if you trust this " +
90
+ "code / the outer container",
91
+ };
92
+ }
93
+ /**
94
+ * The bubblewrap confinement argv (everything before the command): a fresh
95
+ * network namespace (`--unshare-all`, loopback-only, no egress), a read-only
96
+ * system, writable mounts limited to the work dir, the IO dir, and a fresh empty
97
+ * HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
98
+ * no host credentials/config leak in), and a **cleared environment** —
99
+ * `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
100
+ * HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
101
+ * Pure, so the confinement shape is asserted in a unit test.
102
+ */
103
+ function bwrapArgs(opts) {
104
+ return [
105
+ // New user/net/pid/ipc/uts/cgroup namespaces. The net namespace has only a
106
+ // loopback route, so the in-sandbox mock is reachable but egress is blocked.
107
+ "--unshare-all",
108
+ // Drop ALL inherited env (host secrets); only the essentials are set back.
109
+ "--clearenv",
110
+ "--ro-bind",
111
+ "/",
112
+ "/",
113
+ "--dev",
114
+ "/dev",
115
+ "--proc",
116
+ "/proc",
117
+ // Writable: the work dir and the IO dir (later binds override the ro-bind).
118
+ "--bind",
119
+ opts.cwd,
120
+ opts.cwd,
121
+ "--bind",
122
+ opts.ioDir,
123
+ opts.ioDir,
124
+ // A fresh empty HOME so no host credentials/config are visible.
125
+ "--setenv",
126
+ "HOME",
127
+ opts.home,
128
+ "--setenv",
129
+ "TMPDIR",
130
+ opts.ioDir,
131
+ // PATH must be set back explicitly (cleared above) so node/claude resolve.
132
+ "--setenv",
133
+ "PATH",
134
+ opts.path,
135
+ "--chdir",
136
+ opts.cwd,
137
+ "--die-with-parent",
138
+ "--new-session",
139
+ ];
140
+ }
141
+ /** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
142
+ function parseRequestLog(ndjson) {
143
+ const out = [];
144
+ for (const line of ndjson.split("\n")) {
145
+ if (!line.trim())
146
+ continue;
147
+ try {
148
+ out.push(JSON.parse(line));
149
+ }
150
+ catch {
151
+ /* a partially-written final line — skip */
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ /**
157
+ * Co-launch the scripted mock and `claude` inside ONE bubblewrap network
158
+ * namespace: the mock serves on the sandbox's loopback (reachable), egress is
159
+ * blocked, and captured requests stream out through the bound IO dir. Paths come
160
+ * in via env so the wrapper needs no escaping; `claude`'s args are the wrapper's
161
+ * positional params (`"$@"`).
162
+ */
163
+ const WRAPPER = [
164
+ // start the in-sandbox mock; it writes its port to $VIG_PORT when ready
165
+ 'node "$VIG_MOCKENTRY" "$VIG_SCRIPT" "$VIG_REQS" "$VIG_PORT" &',
166
+ "MOCKPID=$!",
167
+ "i=0",
168
+ 'while [ ! -s "$VIG_PORT" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done',
169
+ 'export ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$VIG_PORT")"',
170
+ "export ANTHROPIC_API_KEY=sk-vigiles-mock",
171
+ 'claude "$@"',
172
+ "code=$?",
173
+ 'kill "$MOCKPID" 2>/dev/null',
174
+ 'exit "$code"',
175
+ ].join("\n");
176
+ /* v8 ignore start -- spawns bwrap + the real claude CLI; exercised by the
177
+ bwrap-backed integration test (skipped without bwrap), not the unit gate —
178
+ the pure policy/args/parse helpers above carry the testable logic. */
179
+ function runSandboxed(opts) {
180
+ const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-sbx-"));
181
+ const home = (0, node_path_1.join)(ioDir, "home");
182
+ (0, node_fs_1.mkdirSync)(home);
183
+ const scriptF = (0, node_path_1.join)(ioDir, "script.json");
184
+ const reqsF = (0, node_path_1.join)(ioDir, "requests.ndjson");
185
+ const portF = (0, node_path_1.join)(ioDir, "port");
186
+ (0, node_fs_1.writeFileSync)(scriptF, JSON.stringify(opts.script));
187
+ (0, node_fs_1.writeFileSync)(reqsF, "");
188
+ // The mock entry is only runnable as built JS. In production __dirname is
189
+ // dist/ (sibling); under vitest the source runs from src/, so fall back to
190
+ // the built dist/ copy.
191
+ const mockEntry = [
192
+ (0, node_path_1.join)(__dirname, "mock-entry.js"),
193
+ (0, node_path_1.join)(__dirname, "..", "dist", "mock-entry.js"),
194
+ ].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "mock-entry.js");
195
+ const args = [
196
+ ...bwrapArgs({
197
+ cwd: opts.cwd,
198
+ ioDir,
199
+ home,
200
+ path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
201
+ }),
202
+ "--setenv",
203
+ "VIG_MOCKENTRY",
204
+ mockEntry,
205
+ "--setenv",
206
+ "VIG_SCRIPT",
207
+ scriptF,
208
+ "--setenv",
209
+ "VIG_REQS",
210
+ reqsF,
211
+ "--setenv",
212
+ "VIG_PORT",
213
+ portF,
214
+ "sh",
215
+ "-c",
216
+ WRAPPER,
217
+ "sh",
218
+ ...opts.claudeArgs,
219
+ ];
220
+ return new Promise((resolvePromise) => {
221
+ const child = (0, node_child_process_1.spawn)("bwrap", args, {
222
+ cwd: opts.cwd,
223
+ env: process.env,
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ });
226
+ let stdout = "";
227
+ child.stdout.on("data", (d) => (stdout += d.toString()));
228
+ child.stderr.on("data", () => {
229
+ /* hook diagnostics — not needed for the captured result */
230
+ });
231
+ const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
232
+ child.on("close", (code) => {
233
+ clearTimeout(timer);
234
+ const requests = parseRequestLog((0, node_fs_1.existsSync)(reqsF) ? (0, node_fs_1.readFileSync)(reqsF, "utf-8") : "");
235
+ (0, node_fs_1.rmSync)(ioDir, { recursive: true, force: true });
236
+ resolvePromise({ code: code ?? 0, stdout, requests });
237
+ });
238
+ });
239
+ }
240
+ /* v8 ignore stop */
241
+ //# sourceMappingURL=sandbox.js.map
package/dist/spec.d.ts CHANGED
@@ -320,6 +320,136 @@ export interface SkillSpec {
320
320
  * export default skill({ name: "my-skill", description: "...", body: "..." });
321
321
  */
322
322
  export declare function skill(spec: Omit<SkillSpec, "_specType">): SkillSpec;
323
+ /**
324
+ * A subagent definition (compiles to `agents/<name>.md`). Unlike a skill —
325
+ * reference material the model reads on activation — a subagent is a *delegated
326
+ * worker with a contract*: a dispatch `description`, an allowed-`tools` rail, an
327
+ * optional `model`, a system-prompt `body`, and the `rules` it must follow. That
328
+ * tool contract + those rules are the "railway" a subagent runs on, and they're
329
+ * exactly the compile-time-verifiable surface vigiles owns: the body's
330
+ * `file()`/`cmd()`/`symbol()` marks are checked like any instruction file, and
331
+ * the tools list is verified against the real tool set.
332
+ */
333
+ export interface AgentSpec {
334
+ readonly _specType: "agent";
335
+ /** Subagent name (frontmatter + dispatch handle). */
336
+ readonly name: string;
337
+ /** When to dispatch this subagent — the trigger (frontmatter). */
338
+ readonly description: string;
339
+ /** Model alias (e.g. "sonnet", "opus", "haiku", "inherit"). Optional. */
340
+ readonly model?: string;
341
+ /**
342
+ * The allowed-tools contract — the rails the worker runs on. Each entry must be
343
+ * a known built-in tool (Read/Write/Edit/Bash/Grep/Glob/WebSearch/WebFetch/
344
+ * NotebookEdit/TodoWrite/Task/Skill) or an MCP tool (`mcp__server__tool`).
345
+ * Omit to inherit all tools. Verified at compile time.
346
+ */
347
+ readonly tools?: readonly string[];
348
+ /**
349
+ * The lead/intro prose of the system prompt (the "You are…" opener), before any
350
+ * sections. Carries verified `file()`/`cmd()`/`symbol()`/`ref()` marks. No
351
+ * markdown headers — use `sections` for those.
352
+ */
353
+ readonly body?: string | InstructionFragment[];
354
+ /**
355
+ * Named `##` sections of the system prompt (e.g. Purpose, Core Principles,
356
+ * Capabilities) — the shape real subagents actually take. Same verified-ref +
357
+ * no-nested-`##` rules as a CLAUDE.md spec's sections. Use `body` for the intro
358
+ * and `sections` for the structured rest.
359
+ */
360
+ readonly sections?: Record<string, string | InstructionFragment[]>;
361
+ /** Rules the worker must follow — rendered as a `## Rules` section. */
362
+ readonly rules?: Record<string, Rule>;
363
+ /**
364
+ * The typed result contract — what this worker returns on success/error. When
365
+ * set, compiles to an `## Output contract` section instructing the worker to
366
+ * end with a `vigiles:ok` / `vigiles:err` block, so its outcome is parseable
367
+ * and testable (see `result()`, `parseAgentResult`, `assertAgentOk`).
368
+ */
369
+ readonly output?: OutputContract;
370
+ }
371
+ /**
372
+ * Define a subagent specification (compiles to `agents/<name>.md`).
373
+ *
374
+ * // agents/reviewer.md.spec.ts
375
+ * export default agent({
376
+ * name: "reviewer",
377
+ * description: "Review a diff for correctness. Dispatch PROACTIVELY after edits.",
378
+ * model: "sonnet",
379
+ * tools: ["Read", "Grep", "Bash"],
380
+ * body: instructions`Review the diff. Run ${cmd("npm test")} first.`,
381
+ * rules: {
382
+ * "no-floating": enforce("@typescript-eslint/no-floating-promises", "Await promises."),
383
+ * },
384
+ * });
385
+ */
386
+ export declare function agent(spec: Omit<AgentSpec, "_specType">): AgentSpec;
387
+ /** The field types a result contract can declare (kept tiny + dependency-free). */
388
+ export type OutputFieldType = "string" | "number" | "boolean" | "string[]";
389
+ /**
390
+ * A subagent's typed result contract: the shape it must return on success
391
+ * (`ok`) and on failure (`err`). Rich on both tracks — an error is structured
392
+ * detail, not a bare pass/fail bit. Compiles into the worker's system prompt
393
+ * (the `vigiles:ok` / `vigiles:err` block it must emit) and is the schema the
394
+ * `parseAgentResult` parser + the `assertAgentOk/Err` test helpers validate.
395
+ */
396
+ export interface OutputContract {
397
+ readonly _ref: "output";
398
+ readonly ok: Readonly<Record<string, OutputFieldType>>;
399
+ readonly err: Readonly<Record<string, OutputFieldType>>;
400
+ }
401
+ /**
402
+ * Declare a subagent's success/error result contract.
403
+ *
404
+ * result(
405
+ * { files: "string[]", summary: "string" }, // rich success
406
+ * { reason: "string", retryable: "boolean" }, // rich error
407
+ * )
408
+ *
409
+ * (Distinct from a skill's `result:` postcondition gate — this types a
410
+ * subagent's *return value*, the success/error tracks of the railway.)
411
+ */
412
+ export declare function result(ok: Record<string, OutputFieldType>, err: Record<string, OutputFieldType>): OutputContract;
413
+ /** One step on a railway: dispatch a flat subagent (the "activity"). */
414
+ export interface RailwayStep {
415
+ readonly _step: "delegate";
416
+ /** The subagent to dispatch — resolved against compiled agent names. */
417
+ readonly agent: string;
418
+ /** Optional task hint passed to the worker. */
419
+ readonly task?: string;
420
+ }
421
+ /** Build a railway step that dispatches `agent` (optionally with a task hint). */
422
+ export declare function delegate(agent: string, task?: string): RailwayStep;
423
+ /**
424
+ * A railway over flat subagents. `steps` run in order on the success track; the
425
+ * first step that returns an error short-circuits to `onError`. `recover`
426
+ * optionally retries the failing step a *bounded* number of times before the
427
+ * error track. There is intentionally no loop combinator — the value is a finite
428
+ * tree, so it always terminates and is fully verifiable at compile time.
429
+ */
430
+ export interface Railway {
431
+ readonly _specType: "railway";
432
+ readonly name: string;
433
+ readonly steps: readonly RailwayStep[];
434
+ /** Error track — runs with the failing step's error payload. */
435
+ readonly onError?: RailwayStep;
436
+ /** Bounded recovery: retry the failing step up to `max` times (finite). */
437
+ readonly recover?: {
438
+ readonly step: RailwayStep;
439
+ readonly max: number;
440
+ };
441
+ }
442
+ /**
443
+ * Compose flat subagents into a railway (compiles to an orchestrator command).
444
+ *
445
+ * railway({
446
+ * name: "ship",
447
+ * steps: [delegate("planner"), delegate("coder"), delegate("reviewer")],
448
+ * onError: delegate("reporter"),
449
+ * recover: { step: delegate("fixer"), max: 2 },
450
+ * })
451
+ */
452
+ export declare function railway(spec: Omit<Railway, "_specType">): Railway;
323
453
  /** Derive the spec filename from an output filename. */
324
454
  export type SpecPath<Output extends `${string}.md`> = `${Output}.spec.ts`;
325
455
  /** Extract the output filename from a spec filename. */