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.
- package/README.md +214 -190
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/cli.js +155 -1
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +180 -9
- package/dist/eval.js +319 -57
- package/dist/harness-assert.d.ts +175 -6
- package/dist/harness-assert.js +355 -4
- package/dist/harness-test.d.ts +130 -5
- package/dist/harness-test.js +205 -32
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/mcp.d.ts +48 -0
- package/dist/mcp.js +247 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/sandbox.d.ts +76 -0
- package/dist/sandbox.js +241 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/package.json +7 -3
package/dist/mock-model.d.ts
CHANGED
|
@@ -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
|
package/dist/mock-model.js
CHANGED
|
@@ -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
|
});
|
package/dist/plugin-loader.js
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
function
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
package/dist/sandbox.js
ADDED
|
@@ -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. */
|