vigiles 2.4.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/README.md +72 -327
- 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/claude-code.d.ts +10 -0
- package/dist/claude-code.js +26 -0
- package/dist/cli.js +106 -0
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/egress-proxy.d.ts +2 -0
- package/dist/egress-proxy.js +60 -0
- package/dist/eval-baseline.d.ts +68 -0
- package/dist/eval-baseline.js +173 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +172 -9
- package/dist/eval.js +319 -58
- package/dist/harness-assert.d.ts +175 -13
- package/dist/harness-assert.js +358 -25
- package/dist/harness-test.d.ts +97 -11
- package/dist/harness-test.js +147 -37
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/linting.d.ts +9 -0
- package/dist/linting.js +25 -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/run-hook.d.ts +81 -1
- package/dist/run-hook.js +189 -11
- package/dist/sandbox.d.ts +107 -0
- package/dist/sandbox.js +307 -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/dist/testing.d.ts +12 -0
- package/dist/testing.js +28 -0
- package/package.json +10 -4
package/dist/run-hook.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type SandboxMode, type EgressAttempt } from "./sandbox.js";
|
|
2
|
+
export type { EgressAttempt };
|
|
1
3
|
/** A hook event payload (the JSON Claude Code writes to the hook's stdin). */
|
|
2
4
|
export interface HookInput {
|
|
3
5
|
/** e.g. "PreToolUse", "PostToolUse", "Stop", "SessionStart", "PreCompact". */
|
|
@@ -38,6 +40,41 @@ export interface RunHookOptions {
|
|
|
38
40
|
readonly env?: Record<string, string>;
|
|
39
41
|
/** Per-run timeout ms. Default 10000. */
|
|
40
42
|
readonly timeoutMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Provenance of the hook command. `true` (default) means YOU authored it — the
|
|
45
|
+
* usual case at this tier, a command written inline in the test — so it runs
|
|
46
|
+
* directly. `false` marks it foreign (a vendored third-party hook script),
|
|
47
|
+
* which makes confinement the DEFAULT: with no explicit `sandbox`, an untrusted
|
|
48
|
+
* hook behaves as `sandbox: "auto"` — confined under bubblewrap, or refused if
|
|
49
|
+
* none is available — so foreign code is never run unconfined by accident. This
|
|
50
|
+
* mirrors the harness tier, where trust follows `plugin`/`pluginDir`
|
|
51
|
+
* provenance (`specTrusted` in `src/sandbox.ts`); the unit tier takes a raw
|
|
52
|
+
* command string with no provenance signal, so you declare it here.
|
|
53
|
+
*/
|
|
54
|
+
readonly trusted?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Confine the hook under bubblewrap (Linux). When unset, the mode follows
|
|
57
|
+
* {@link RunHookOptions.trusted}: a trusted hook runs directly (`false`), an
|
|
58
|
+
* untrusted one is confined-or-refused (`"auto"`). Set it explicitly to
|
|
59
|
+
* override: `"auto"`/`"strict"` force confinement (a no-egress namespace with a
|
|
60
|
+
* cleared environment — your `opts.env` is added back — or a **refusal** if no
|
|
61
|
+
* bwrap is available), and `false` is the opt-out that runs even untrusted code
|
|
62
|
+
* unconfined. macOS/Windows have no bwrap, so `"auto"`/`"strict"` throw there —
|
|
63
|
+
* see `src/sandbox.ts`.
|
|
64
|
+
*/
|
|
65
|
+
readonly sandbox?: SandboxMode;
|
|
66
|
+
/**
|
|
67
|
+
* Record the hook's network egress. Implies confinement (the recorder lives in
|
|
68
|
+
* the sandbox netns, so this forces a sandboxed run and refuses if no sandbox is
|
|
69
|
+
* available). A recording proxy on loopback captures every `host:port` a
|
|
70
|
+
* proxy-honoring tool (npm/pip/curl/fetch) tries to reach — surfaced as
|
|
71
|
+
* {@link HookRunResult.egress} — while the netns still **blocks** it (nothing
|
|
72
|
+
* actually leaves). Use it to test what a hook/skill phones home to, or which
|
|
73
|
+
* registry an install would hit. Raw-socket egress is blocked but not recorded
|
|
74
|
+
* (it never reaches the proxy) — the block is the boundary, the record is
|
|
75
|
+
* best-effort observability over it.
|
|
76
|
+
*/
|
|
77
|
+
readonly recordEgress?: boolean;
|
|
41
78
|
}
|
|
42
79
|
export interface HookRunResult {
|
|
43
80
|
readonly exitCode: number;
|
|
@@ -50,6 +87,17 @@ export interface HookRunResult {
|
|
|
50
87
|
* `permissionDecision:"deny"` all set `blocked = true`.
|
|
51
88
|
*/
|
|
52
89
|
readonly blocked: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Network egress the hook attempted, recorded then blocked. Empty unless
|
|
92
|
+
* {@link RunHookOptions.recordEgress} was set (and the run was confined).
|
|
93
|
+
*/
|
|
94
|
+
readonly egress: readonly EgressAttempt[];
|
|
95
|
+
/**
|
|
96
|
+
* Files the hook wrote to its work dir (relative paths), recorded on confined
|
|
97
|
+
* runs — what a hook touched on disk. Empty on a direct (unconfined) run.
|
|
98
|
+
* Assert over it with `assertNoWrite` / `assertWroteOnly`.
|
|
99
|
+
*/
|
|
100
|
+
readonly filesWritten: readonly string[];
|
|
53
101
|
/**
|
|
54
102
|
* The decision the hook expressed, preferring the structured
|
|
55
103
|
* `permissionDecision` ("allow"|"deny"|"ask") then legacy `decision`
|
|
@@ -67,11 +115,43 @@ export declare function decideHook(exitCode: number, json: HookOutput | null): {
|
|
|
67
115
|
blocked: boolean;
|
|
68
116
|
decision: HookRunResult["decision"];
|
|
69
117
|
};
|
|
118
|
+
/** The raw fields of a hook spawn that the result parser needs. */
|
|
119
|
+
export interface HookSpawnResult {
|
|
120
|
+
readonly status: number | null;
|
|
121
|
+
readonly signal: string | null;
|
|
122
|
+
readonly stdout: string;
|
|
123
|
+
readonly stderr: string;
|
|
124
|
+
/** Egress attempts captured by the in-sandbox recorder (recordEgress only). */
|
|
125
|
+
readonly egress?: readonly EgressAttempt[];
|
|
126
|
+
/** Files the hook wrote to its work dir (confined runs). */
|
|
127
|
+
readonly filesWritten?: readonly string[];
|
|
128
|
+
}
|
|
129
|
+
/** Spawn a hook (command + piped event) — the injectable seam over the real spawn. */
|
|
130
|
+
export type HookSpawner = (command: string, input: HookInput, opts: RunHookOptions) => HookSpawnResult;
|
|
131
|
+
/** The spawn seams `runHookWith` needs, so its decision logic is testable. */
|
|
132
|
+
export interface RunHookDeps {
|
|
133
|
+
/** Whether bubblewrap confinement is available (Linux + bwrap). */
|
|
134
|
+
readonly available: boolean;
|
|
135
|
+
/** Run the command directly (unconfined). */
|
|
136
|
+
readonly direct: HookSpawner;
|
|
137
|
+
/** Run the command confined under bubblewrap. */
|
|
138
|
+
readonly sandboxed: HookSpawner;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The hook-run orchestration with injectable spawn seams: pick direct vs.
|
|
142
|
+
* confined via the safe-by-default policy (`decideSandbox`), then parse the exit
|
|
143
|
+
* code + stdout into a normalized decision. Exported so all three branches
|
|
144
|
+
* (direct / sandbox / refuse) are unit-tested with fake spawners — no real
|
|
145
|
+
* bwrap. `runHook` is this with the real seams.
|
|
146
|
+
*/
|
|
147
|
+
export declare function runHookWith(command: string, input: HookInput, opts: RunHookOptions, deps: RunHookDeps): HookRunResult;
|
|
70
148
|
/**
|
|
71
149
|
* Run a hook command, piping `input` as JSON to its stdin, and report the exit
|
|
72
150
|
* code + parsed decision. Synchronous (so it can be used inside an eval's
|
|
73
151
|
* `measure` too). `command` is run through a shell, so the same command string a
|
|
74
|
-
* plugin ships (with args / env refs) works verbatim.
|
|
152
|
+
* plugin ships (with args / env refs) works verbatim. Mark a hook you didn't
|
|
153
|
+
* write with `trusted: false` and it is confined by default (or pass `sandbox:
|
|
154
|
+
* "auto"` directly) — see {@link RunHookOptions.trusted}.
|
|
75
155
|
*/
|
|
76
156
|
export declare function runHook(command: string, input: HookInput, opts?: RunHookOptions): HookRunResult;
|
|
77
157
|
//# sourceMappingURL=run-hook.d.ts.map
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
@@ -0,0 +1,107 @@
|
|
|
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 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.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sandboxAvailable(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Is this spec's executed code trusted? Inline `settings`/`files` you authored
|
|
25
|
+
* are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
|
|
26
|
+
* and is NOT — committing it to your repo is the same trust decision as a
|
|
27
|
+
* dependency, so the trust boundary follows provenance: foreign = confined.
|
|
28
|
+
*/
|
|
29
|
+
export declare function specTrusted(spec: {
|
|
30
|
+
plugin?: string;
|
|
31
|
+
pluginDir?: string;
|
|
32
|
+
}): boolean;
|
|
33
|
+
/** The chosen action for a run: execute directly, confine it, or refuse. */
|
|
34
|
+
export type SandboxDecision = {
|
|
35
|
+
readonly action: "direct";
|
|
36
|
+
} | {
|
|
37
|
+
readonly action: "sandbox";
|
|
38
|
+
} | {
|
|
39
|
+
readonly action: "throw";
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
|
|
44
|
+
* the caller explicitly opted out (`mode: false`). This is the whole security
|
|
45
|
+
* contract, isolated as a pure function so it is exhaustively unit-tested.
|
|
46
|
+
*/
|
|
47
|
+
export declare function decideSandbox(opts: {
|
|
48
|
+
trusted: boolean;
|
|
49
|
+
mode: SandboxMode;
|
|
50
|
+
available: boolean;
|
|
51
|
+
}): SandboxDecision;
|
|
52
|
+
/**
|
|
53
|
+
* The bubblewrap confinement argv (everything before the command): a fresh
|
|
54
|
+
* network namespace (`--unshare-all`, loopback-only, no egress), a read-only
|
|
55
|
+
* system, writable mounts limited to the work dir, the IO dir, and a fresh empty
|
|
56
|
+
* HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
|
|
57
|
+
* no host credentials/config leak in), and a **cleared environment** —
|
|
58
|
+
* `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
|
|
59
|
+
* HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
|
|
60
|
+
* Pure, so the confinement shape is asserted in a unit test.
|
|
61
|
+
*/
|
|
62
|
+
export declare function bwrapArgs(opts: {
|
|
63
|
+
cwd: string;
|
|
64
|
+
ioDir: string;
|
|
65
|
+
home: string;
|
|
66
|
+
path: string;
|
|
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[];
|
|
74
|
+
/** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
|
|
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[];
|
|
95
|
+
/** The raw output of a sandboxed run: exit code, captured stdout, and requests. */
|
|
96
|
+
export interface SandboxRunOut {
|
|
97
|
+
readonly code: number;
|
|
98
|
+
readonly stdout: string;
|
|
99
|
+
readonly requests: readonly ModelRequest[];
|
|
100
|
+
}
|
|
101
|
+
export declare function runSandboxed(opts: {
|
|
102
|
+
cwd: string;
|
|
103
|
+
claudeArgs: readonly string[];
|
|
104
|
+
script: readonly ModelTurn[];
|
|
105
|
+
timeoutMs: number;
|
|
106
|
+
}): Promise<SandboxRunOut>;
|
|
107
|
+
//# sourceMappingURL=sandbox.d.ts.map
|