@zenera/sandbox 1.1.59 → 1.2.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 CHANGED
@@ -48,6 +48,22 @@ by name, across copies of this package).
48
48
  - **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
49
49
  binding host directories, with `fs` a `RootedFs` over them under the names
50
50
  they are bound at. `ensurePodmanImage` is its image step on its own.
51
+ - **`BwrapSandbox`** / `bwrapSandbox` - for a host that is itself the
52
+ container, such as the agent image: each command runs under bubblewrap
53
+ (`/opt/zenera/bin/bwrap`, else `bwrap` on PATH) in its own user, pid, ipc and
54
+ uts namespaces, on a read-only view of the image, with the session's
55
+ directories bound in and the server's own paths (`/state`, `/run/secrets`)
56
+ hidden. bwrap is started with exactly the command's environment, never this
57
+ process's: the image's `ENV` (`/opt/zenera/etc/image-env.json`, written when
58
+ the agent image is built; else `PATH`, `LANG`, `LC_*`, `TZ`) plus the run's.
59
+ `/proc` is the command's own where the container lets one be
60
+ mounted, else the container's bound read-only (`probeBwrap()` says which, or
61
+ why bwrap cannot isolate anything; nothing falls back to running unisolated).
62
+ `prepare()` builds nothing: it checks that the container was built from the
63
+ project's Dockerfile (`ZEN_IMAGE_BUILD`) or image (`ZEN_IMAGE_BASE`), when
64
+ it says what it was built from.
65
+ `cpus`, `memory` and `persist` belong to the container and are ignored;
66
+ commands get `oom_score_adj` 1000, so they go before the server.
51
67
  - A sandbox that keeps its files with the container (a sandbox service) serves
52
68
  `fs` itself, under the same names.
53
69
 
@@ -0,0 +1,143 @@
1
+ import { type Mount } from './mounts.ts';
2
+ import { type Runner } from './process.ts';
3
+ import { RootedFs } from './rooted-fs.ts';
4
+ import { type BuildSpec, type ExecOptions, type ExecResult, type JobOutput, type JobStopped, type PrepareOptions, type Sandbox, type SandboxFactory, type SandboxHardening, type SandboxNetwork, type SandboxOptions } from './contract.ts';
5
+ /** Where the agent image keeps its bwrap: a static build, so any base runs it. */
6
+ export declare const ZENERA_BWRAP = "/opt/zenera/bin/bwrap";
7
+ /**
8
+ * The project image's own environment (its Dockerfile's `ENV` lines), written by the agent
9
+ * image's build as a JSON object: what a command gets to start from, as it would in that image.
10
+ */
11
+ export declare const IMAGE_ENV_FILE = "/opt/zenera/etc/image-env.json";
12
+ /** Set by `zen image build`: what the image was built from. */
13
+ export declare const IMAGE_BUILD_ENV = "ZEN_IMAGE_BUILD";
14
+ export declare const IMAGE_BASE_ENV = "ZEN_IMAGE_BASE";
15
+ /** `--proc`: a `/proc` of the command's own, or the container's, bound read-only. */
16
+ export type ProcMode = 'fresh' | 'bind';
17
+ export interface BwrapProbe {
18
+ ok: boolean;
19
+ /** the binary probed */
20
+ bwrap: string;
21
+ /** how `/proc` is given to commands, when `ok` */
22
+ proc?: ProcMode;
23
+ /** why not, when not `ok` */
24
+ error?: string;
25
+ hint?: string;
26
+ }
27
+ /** What a host can tell the sandbox about where it runs; the defaults are the agent image's. */
28
+ export interface BwrapHost {
29
+ /** the bwrap binary; `/opt/zenera/bin/bwrap`, else `bwrap` on PATH */
30
+ bwrap?: string;
31
+ /** where the base environment and secrets are read from; `process.env` */
32
+ env?: NodeJS.ProcessEnv;
33
+ /** more paths no command sees, besides `/state` and `/run/secrets` (always hidden) */
34
+ hidden?: readonly string[];
35
+ /**
36
+ * What this image was built from, which `prepare` checks the spec against;
37
+ * from `ZEN_IMAGE_BUILD` / `ZEN_IMAGE_BASE` when unset. Neither known, or
38
+ * `false`: no check — the container is whatever its host made it.
39
+ */
40
+ image?: false | {
41
+ build?: string;
42
+ base?: string;
43
+ };
44
+ /**
45
+ * What a command's environment starts from: the image's own `ENV` (IMAGE_ENV_FILE, when the
46
+ * container has it), else PATH, LANG, LC_* and TZ of this process. Never the rest of it.
47
+ */
48
+ imageEnv?: Record<string, string>;
49
+ /** how processes are spawned — the seam tests replace */
50
+ exec?: Runner;
51
+ /** the host's filesystem as the root is built from it — the seam tests replace */
52
+ tree?: HostTree;
53
+ }
54
+ /** The host's filesystem, as far as building a command's root needs it. */
55
+ export interface HostTree {
56
+ kind(path: string): 'dir' | 'file' | 'symlink' | 'missing';
57
+ list(path: string): string[];
58
+ link(path: string): string;
59
+ }
60
+ export declare const hostTree: HostTree;
61
+ interface Resolved {
62
+ root: string;
63
+ key: string;
64
+ image: string;
65
+ build?: BuildSpec;
66
+ network: SandboxNetwork;
67
+ workdir: string;
68
+ timeout: number;
69
+ hardening: SandboxHardening;
70
+ user?: {
71
+ uid: string;
72
+ gid?: string;
73
+ };
74
+ env: Record<string, string>;
75
+ secrets: readonly string[];
76
+ readOnly: boolean;
77
+ mounts: readonly Mount[];
78
+ execEnv: Record<string, string>;
79
+ execPaths: Record<string, readonly string[]>;
80
+ }
81
+ export declare class BwrapSandbox implements Sandbox {
82
+ #private;
83
+ readonly name: string;
84
+ readonly fs: RootedFs;
85
+ readonly spec: Resolved;
86
+ constructor(opts: SandboxOptions, host?: BwrapHost);
87
+ get started(): boolean;
88
+ /** Checks that this container is the image the project describes; builds nothing. */
89
+ prepare(opts?: PrepareOptions): Promise<void>;
90
+ start(): Promise<void>;
91
+ exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
92
+ /**
93
+ * Starts a command and returns. Its log is a file of this process's, outside
94
+ * the command's view; its exit code is the one bwrap reports.
95
+ */
96
+ startJob(command: string, opts?: ExecOptions): Promise<string>;
97
+ readJob(id: string, from?: number): Promise<JobOutput>;
98
+ /**
99
+ * Ends bwrap, and with it everything the job started: the command is the
100
+ * first process of a pid namespace, and a namespace whose first process
101
+ * dies takes the rest with it.
102
+ */
103
+ stopJob(id: string): Promise<JobStopped>;
104
+ /** Ends every command and job; the files stay where the mounts keep them. */
105
+ close(): Promise<void>;
106
+ /** A path inside the sandbox, contained to the working directory. */
107
+ inside(path?: string): string;
108
+ }
109
+ export declare const bwrapSandbox: SandboxFactory;
110
+ export interface BwrapArgs {
111
+ tree: HostTree;
112
+ hidden: readonly string[];
113
+ /** shallowest first */
114
+ binds: readonly {
115
+ at: string;
116
+ host: string;
117
+ readOnly: boolean;
118
+ }[];
119
+ proc: ProcMode;
120
+ network: SandboxNetwork;
121
+ strict: boolean;
122
+ user?: {
123
+ uid: string;
124
+ gid?: string;
125
+ };
126
+ cwd: string;
127
+ /** more mounts, after `/tmp` exists */
128
+ extra?: readonly string[];
129
+ command: readonly string[];
130
+ }
131
+ export declare function bwrapArgs(a: BwrapArgs): string[];
132
+ /**
133
+ * The host's root, bound piece by piece: `targets` are mounted later, `omit`
134
+ * is never shown. A directory is opened only for a mount point it lacks;
135
+ * whatever it holds is then bound entry by entry, so this stays rare — under
136
+ * SELinux a bind of something the runtime itself mounted can be refused.
137
+ */
138
+ export declare function rootArgs(tree: HostTree, targets: readonly string[], omit: readonly string[]): string[];
139
+ /** What is hidden inside a bound directory is covered: an empty tmpfs, or /dev/null. */
140
+ export declare function hiddenArgs(tree: HostTree, hidden: readonly string[]): string[];
141
+ export declare function probeBwrap(bwrap?: string, run?: Runner): Promise<BwrapProbe>;
142
+ export {};
143
+ //# sourceMappingURL=bwrap.d.ts.map
package/dist/bwrap.js ADDED
@@ -0,0 +1,707 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { closeSync, createReadStream, existsSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from 'node:fs';
4
+ import { constants, tmpdir } from 'node:os';
5
+ import { delimiter, join, posix } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import { buildIdentity } from "./image.js";
8
+ import { sandboxLimits } from "./limits.js";
9
+ import { WORKSPACE_MOUNT } from "./mounts.js";
10
+ import { MAX_TIMEOUT_MS, runProcess } from "./process.js";
11
+ import { RootedFs } from "./rooted-fs.js";
12
+ import { DEFAULT_SANDBOX_IMAGE, SandboxError, } from "./contract.js";
13
+ // ---------------------------------------------------------------------------
14
+ // The bwrap sandbox
15
+ //
16
+ // `Sandbox` for a host that is itself the sandbox's container: the agent
17
+ // image, where the server, the project's environment and the agent's commands
18
+ // share one root filesystem. Each command runs under bubblewrap in namespaces
19
+ // of its own — its own processes, a read-only view of the image, the mounts it
20
+ // is given and nothing of the server's: not its environment, not its state.
21
+ //
22
+ // Nothing is pulled or built here; the image is whatever this container runs.
23
+ // `prepare` only checks that it is the image the project describes.
24
+ //
25
+ // What a container sets for the whole container is not this sandbox's to set:
26
+ // `cpus`, `memory` and `persist` are ignored. Commands are made the first
27
+ // thing the kernel's out-of-memory killer takes instead of the server.
28
+ // ---------------------------------------------------------------------------
29
+ /** Where the agent image keeps its bwrap: a static build, so any base runs it. */
30
+ export const ZENERA_BWRAP = '/opt/zenera/bin/bwrap';
31
+ /**
32
+ * The project image's own environment (its Dockerfile's `ENV` lines), written by the agent
33
+ * image's build as a JSON object: what a command gets to start from, as it would in that image.
34
+ */
35
+ export const IMAGE_ENV_FILE = '/opt/zenera/etc/image-env.json';
36
+ /** Set by `zen image build`: what the image was built from. */
37
+ export const IMAGE_BUILD_ENV = 'ZEN_IMAGE_BUILD';
38
+ export const IMAGE_BASE_ENV = 'ZEN_IMAGE_BASE';
39
+ /** The server's own paths, never shown to a command. */
40
+ const DEFAULT_HIDDEN = ['/state', '/run/secrets'];
41
+ /** What a command gets of this process's environment; the rest it is given by name. */
42
+ const BASE_ENV = ['PATH', 'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'TZ'];
43
+ const DEFAULT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
44
+ const MAX_LOG_LINES = 400;
45
+ const DEFAULT_TIMEOUT_MS = 120_000;
46
+ const STRICT_TMP_BYTES = 64 * 1024 * 1024;
47
+ const STOP_GRACE_MS = 1000;
48
+ /** SIGKILL, which is how the kernel's out-of-memory killer arrives. */
49
+ const KILLED = 137;
50
+ /** Where a job's script appears, inside the command's own `/tmp`. */
51
+ const JOB_SCRIPT = '/tmp/.zenera-job.sh';
52
+ /**
53
+ * Commands go first when memory runs out, the server last. Set by a shell of
54
+ * ours just before it becomes bwrap, so everything in the sandbox inherits it
55
+ * (raising one's own score needs no privilege), whether or not the sandbox's
56
+ * `/proc` is writable.
57
+ */
58
+ const LAUNCH = ['-c', '{ echo 1000 > /proc/self/oom_score_adj; } 2>/dev/null; exec "$0" "$@"'];
59
+ const CGROUP = '/sys/fs/cgroup';
60
+ export const hostTree = {
61
+ kind(path) {
62
+ try {
63
+ const s = lstatSync(path);
64
+ return s.isSymbolicLink() ? 'symlink' : s.isDirectory() ? 'dir' : 'file';
65
+ }
66
+ catch {
67
+ return 'missing';
68
+ }
69
+ },
70
+ list: (path) => readdirSync(path),
71
+ link: (path) => readlinkSync(path),
72
+ };
73
+ export class BwrapSandbox {
74
+ name;
75
+ fs;
76
+ spec;
77
+ #host;
78
+ #run;
79
+ #tree;
80
+ #jobs = new Map();
81
+ #bwrap;
82
+ #proc = 'fresh';
83
+ #dir;
84
+ #closing = new AbortController();
85
+ #ready;
86
+ #prepared;
87
+ #started = false;
88
+ /** kills counted in this container's cgroup as of the last look */
89
+ #kills;
90
+ constructor(opts, host = {}) {
91
+ this.spec = resolveSpec(opts);
92
+ this.#host = host;
93
+ this.#run = host.exec ?? runProcess;
94
+ this.#tree = host.tree ?? hostTree;
95
+ this.name = `zenera-bwrap-${opts.key}`;
96
+ this.fs = new RootedFs([
97
+ { at: this.spec.workdir, dir: this.spec.root, readOnly: this.spec.readOnly },
98
+ ...this.spec.mounts
99
+ .filter((m) => m.source?.kind !== 'credential')
100
+ .map((m) => ({ at: m.at, dir: m.host, readOnly: m.readOnly })),
101
+ ]);
102
+ }
103
+ get started() {
104
+ return this.#started;
105
+ }
106
+ /** Checks that this container is the image the project describes; builds nothing. */
107
+ prepare(opts = {}) {
108
+ if (!this.#prepared || opts.force) {
109
+ this.#prepared = Promise.resolve()
110
+ .then(() => this.#checkImage())
111
+ .catch((err) => {
112
+ this.#prepared = undefined;
113
+ throw err;
114
+ });
115
+ }
116
+ return this.#prepared;
117
+ }
118
+ #checkImage() {
119
+ if (this.#host.image === false) {
120
+ return;
121
+ }
122
+ const env = this.#host.env ?? process.env;
123
+ const have = this.#host.image ?? {
124
+ build: env[IMAGE_BUILD_ENV]?.trim() || undefined,
125
+ base: env[IMAGE_BASE_ENV]?.trim() || undefined,
126
+ };
127
+ // A container `zen image build` did not make says nothing about what it holds; that is
128
+ // the host's call (a development container, a test), not a mismatch.
129
+ if (!have.build && !have.base) {
130
+ return;
131
+ }
132
+ const hint = 'rebuild the image: zen image build';
133
+ if (this.spec.build) {
134
+ const want = buildIdentity(this.spec.build).hash;
135
+ if (!have.build) {
136
+ throw new SandboxError(`this container was not built from the project's sandbox Dockerfile (no ${IMAGE_BUILD_ENV})`, hint);
137
+ }
138
+ if (have.build !== want) {
139
+ throw new SandboxError(`the image was built from another sandbox Dockerfile: ${have.build}, the project's is ${want}`, hint);
140
+ }
141
+ }
142
+ else if (have.base !== this.spec.image) {
143
+ throw new SandboxError(`the image is based on ${have.base ?? 'an unknown image'}, the project asks for ${this.spec.image}`, hint);
144
+ }
145
+ }
146
+ start() {
147
+ this.#ready ??= this.#bring().catch((err) => {
148
+ this.#ready = undefined;
149
+ throw err;
150
+ });
151
+ return this.#ready;
152
+ }
153
+ async #bring() {
154
+ const probe = await probeBwrap(this.#host.bwrap, this.#run);
155
+ if (!probe.ok) {
156
+ throw new SandboxError(`commands cannot be isolated here: ${probe.error}`, probe.hint);
157
+ }
158
+ this.#bwrap = probe.bwrap;
159
+ this.#proc = probe.proc ?? 'bind';
160
+ this.#dir = mkdtempSync(join(tmpdir(), 'zenera-bwrap-'));
161
+ this.#closing = new AbortController();
162
+ this.#kills ??= cgroupKills();
163
+ this.#started = true;
164
+ }
165
+ async exec(command, opts = {}) {
166
+ await this.start();
167
+ const cwd = this.inside(opts.cwd);
168
+ const startedAt = Date.now();
169
+ const signal = opts.signal
170
+ ? AbortSignal.any([opts.signal, this.#closing.signal])
171
+ : this.#closing.signal;
172
+ // The script goes down stdin, so no text of the agent's is ever part of a parse here.
173
+ const res = await this.#run('/bin/sh', [...LAUNCH, this.#bwrap, ...this.#args(cwd, ['/bin/sh', '-s'])], {
174
+ input: command,
175
+ timeoutMs: this.#timeout(opts.timeout),
176
+ signal,
177
+ env: this.#env(),
178
+ });
179
+ refuseSetupFailure(res);
180
+ return {
181
+ exit_code: res.code,
182
+ stdout: res.stdout,
183
+ stderr: res.stderr,
184
+ truncated: res.truncated || undefined,
185
+ timed_out: res.timedOut || undefined,
186
+ duration_ms: Date.now() - startedAt,
187
+ out_of_memory: res.timedOut || signal.aborted ? undefined : this.#oom(res.code),
188
+ };
189
+ }
190
+ /**
191
+ * Starts a command and returns. Its log is a file of this process's, outside
192
+ * the command's view; its exit code is the one bwrap reports.
193
+ */
194
+ async startJob(command, opts = {}) {
195
+ await this.start();
196
+ const cwd = this.inside(opts.cwd);
197
+ const id = jobId();
198
+ const script = join(this.#dir, `${id}.sh`);
199
+ const log = join(this.#dir, `${id}.log`);
200
+ writeFileSync(script, command, { mode: 0o600 });
201
+ const fd = openSync(log, 'a', 0o600);
202
+ let child;
203
+ try {
204
+ child = spawn('/bin/sh', [
205
+ ...LAUNCH,
206
+ this.#bwrap,
207
+ ...this.#args(cwd, ['/bin/sh', JOB_SCRIPT], ['--ro-bind', script, JOB_SCRIPT]),
208
+ ],
209
+ // Its own process group, so stopping it signals bwrap and nothing of ours.
210
+ { stdio: ['ignore', fd, fd], env: this.#env(), detached: true });
211
+ }
212
+ finally {
213
+ closeSync(fd);
214
+ }
215
+ const job = {
216
+ id,
217
+ command,
218
+ startedAt: Date.now(),
219
+ log,
220
+ child,
221
+ ended: new Promise((done) => {
222
+ child.once('exit', (code, sig) => {
223
+ job.exit = code ?? exitOfSignal(sig);
224
+ done();
225
+ });
226
+ child.once('error', (err) => {
227
+ job.exit ??= 127;
228
+ writeFileSync(log, `could not start: ${err.message}\n`, { flag: 'a' });
229
+ done();
230
+ });
231
+ }),
232
+ };
233
+ child.unref();
234
+ this.#jobs.set(id, job);
235
+ return id;
236
+ }
237
+ async readJob(id, from = 1) {
238
+ const job = this.#job(id);
239
+ const first = Math.max(1, Math.trunc(from));
240
+ const { lines, window } = await readWindow(job.log, first, MAX_LOG_LINES);
241
+ if (job.exit === KILLED && !job.oom) {
242
+ job.oom = this.#oom(job.exit);
243
+ }
244
+ const end = first + window.length - 1;
245
+ return {
246
+ job_id: id,
247
+ command: job.command,
248
+ running: job.exit === undefined,
249
+ exit_code: job.exit,
250
+ duration_ms: Date.now() - job.startedAt,
251
+ start_line: first,
252
+ end_line: end,
253
+ lines,
254
+ truncated: end < lines || undefined,
255
+ output: window.length ? `${window.join('\n')}\n` : '',
256
+ out_of_memory: job.oom,
257
+ };
258
+ }
259
+ /**
260
+ * Ends bwrap, and with it everything the job started: the command is the
261
+ * first process of a pid namespace, and a namespace whose first process
262
+ * dies takes the rest with it.
263
+ */
264
+ async stopJob(id) {
265
+ const job = this.#job(id);
266
+ await this.#end(job);
267
+ this.#jobs.delete(id);
268
+ return { job_id: id, stopped: true };
269
+ }
270
+ async #end(job) {
271
+ if (job.exit !== undefined || job.child.pid === undefined) {
272
+ return;
273
+ }
274
+ signalGroup(job.child.pid, 'SIGTERM');
275
+ const ended = await Promise.race([
276
+ job.ended.then(() => true),
277
+ new Promise((r) => setTimeout(() => r(false), STOP_GRACE_MS)),
278
+ ]);
279
+ if (!ended) {
280
+ signalGroup(job.child.pid, 'SIGKILL');
281
+ await job.ended;
282
+ }
283
+ }
284
+ #job(id) {
285
+ const job = this.#jobs.get(id);
286
+ if (!job) {
287
+ throw new SandboxError(`no such job: ${id}`, 'ids come from run_command_background');
288
+ }
289
+ return job;
290
+ }
291
+ /** Ends every command and job; the files stay where the mounts keep them. */
292
+ async close() {
293
+ if (!this.#started) {
294
+ return;
295
+ }
296
+ this.#started = false;
297
+ this.#ready = undefined;
298
+ this.#closing.abort();
299
+ await Promise.all([...this.#jobs.values()].map((job) => this.#end(job)));
300
+ this.#jobs.clear();
301
+ if (this.#dir) {
302
+ rmSync(this.#dir, { recursive: true, force: true });
303
+ this.#dir = undefined;
304
+ }
305
+ }
306
+ /** A path inside the sandbox, contained to the working directory. */
307
+ inside(path) {
308
+ const base = this.spec.workdir;
309
+ if (!path) {
310
+ return base;
311
+ }
312
+ if (path.includes('\0')) {
313
+ throw new SandboxError('path contains a null byte');
314
+ }
315
+ const at = posix.resolve(base, path);
316
+ if (at !== base && !at.startsWith(`${base}/`)) {
317
+ throw new SandboxError(`outside ${base}: ${path}`, `paths are relative to ${base}`);
318
+ }
319
+ return at;
320
+ }
321
+ /** bwrap's arguments for one command. */
322
+ #args(cwd, command, extra = []) {
323
+ const s = this.spec;
324
+ const binds = [
325
+ { at: s.workdir, host: s.root, readOnly: s.readOnly },
326
+ ...s.mounts.map((m) => ({ at: m.at, host: m.host, readOnly: m.readOnly ?? false })),
327
+ ].sort((a, b) => depth(a.at) - depth(b.at));
328
+ return bwrapArgs({
329
+ tree: this.#tree,
330
+ hidden: [
331
+ ...DEFAULT_HIDDEN,
332
+ ...(this.#host.hidden ?? []),
333
+ ...(this.#dir ? [this.#dir] : []),
334
+ ],
335
+ binds,
336
+ proc: this.#proc,
337
+ network: s.network,
338
+ strict: s.hardening === 'strict',
339
+ user: s.user,
340
+ cwd,
341
+ extra,
342
+ command,
343
+ });
344
+ }
345
+ /**
346
+ * Exactly what a command sees, and also what bwrap is started with: bwrap's
347
+ * own environment stays readable to the command (its `/proc/1/environ`), so
348
+ * nothing of the server's may reach bwrap in the first place.
349
+ */
350
+ #env() {
351
+ const host = this.#host.env ?? process.env;
352
+ const image = this.#host.imageEnv ?? imageEnv();
353
+ const env = {};
354
+ if (image) {
355
+ Object.assign(env, image);
356
+ }
357
+ else {
358
+ for (const name of BASE_ENV) {
359
+ const value = host[name];
360
+ if (value !== undefined) {
361
+ env[name] = value;
362
+ }
363
+ }
364
+ }
365
+ env.PATH ??= DEFAULT_PATH;
366
+ Object.assign(env, this.spec.env);
367
+ for (const name of this.spec.secrets) {
368
+ const value = host[name];
369
+ if (value !== undefined) {
370
+ env[name] = value;
371
+ }
372
+ }
373
+ Object.assign(env, this.spec.execEnv);
374
+ for (const [name, dirs] of Object.entries(this.spec.execPaths)) {
375
+ if (dirs.length) {
376
+ env[name] = [...dirs, ...(env[name] ? [env[name]] : [])].join(':');
377
+ }
378
+ }
379
+ return env;
380
+ }
381
+ #timeout(seconds) {
382
+ const wanted = seconds && seconds > 0 ? seconds * 1000 : this.spec.timeout * 1000;
383
+ return Math.min(wanted || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
384
+ }
385
+ /**
386
+ * A bare 137 turned into a reason. The container's cgroup is the only
387
+ * limit there is, so a kill counted against it means the command needs
388
+ * less memory, or the container more; an uncounted one was the machine's.
389
+ */
390
+ #oom(code) {
391
+ if (code !== KILLED) {
392
+ return undefined;
393
+ }
394
+ const limit = cgroupLimitMib();
395
+ if (limit === undefined) {
396
+ return undefined;
397
+ }
398
+ const kills = cgroupKills();
399
+ const own = kills === undefined || kills > (this.#kills ?? 0);
400
+ if (kills !== undefined) {
401
+ this.#kills = kills;
402
+ }
403
+ const peak = cgroupNumber('memory.peak');
404
+ return {
405
+ limit_mib: limit,
406
+ peak_mib: peak === undefined ? undefined : Math.round(peak / (1024 * 1024)),
407
+ retryable: !own,
408
+ hint: own
409
+ ? `this container is limited to ${limit} MiB and the command wanted more. ` +
410
+ `Running it again unchanged will end the same way: do the work in smaller ` +
411
+ `pieces, or give the container more memory.`
412
+ : `the machine ran out of memory while this container was inside its ` +
413
+ `${limit} MiB limit, so the command was killed for something else. It does ` +
414
+ `not need to change, and may work once the machine is less busy.`,
415
+ };
416
+ }
417
+ }
418
+ export const bwrapSandbox = (opts) => new BwrapSandbox(opts);
419
+ /** Paths every command gets of its own, whatever the host has there. */
420
+ const OWN = ['/proc', '/dev', '/tmp'];
421
+ export function bwrapArgs(a) {
422
+ const args = [
423
+ // Explicit, so a server that happens to run as root still puts commands
424
+ // in a user namespace — which is what keeps its `/proc` entries closed.
425
+ '--unshare-user',
426
+ '--die-with-parent',
427
+ '--new-session',
428
+ '--unshare-pid',
429
+ '--unshare-ipc',
430
+ '--unshare-uts',
431
+ ];
432
+ if (a.network === 'none') {
433
+ args.push('--unshare-net');
434
+ }
435
+ if (a.strict) {
436
+ args.push('--cap-drop', 'ALL');
437
+ }
438
+ if (a.user) {
439
+ args.push('--uid', a.user.uid, '--gid', a.user.gid ?? a.user.uid);
440
+ }
441
+ args.push(...rootArgs(a.tree, a.binds.map((b) => b.at), [...OWN, ...a.hidden]));
442
+ args.push(...hiddenArgs(a.tree, a.hidden));
443
+ args.push('--dev-bind', '/dev', '/dev');
444
+ // The bind is recursive: the container's shared memory would come with it,
445
+ // writable and shared with the server and every other session.
446
+ for (const shared of ['/dev/shm', '/dev/mqueue']) {
447
+ if (a.tree.kind(shared) === 'dir') {
448
+ args.push('--tmpfs', shared);
449
+ }
450
+ }
451
+ args.push(...(a.proc === 'fresh' ? ['--proc', '/proc'] : ['--ro-bind', '/proc', '/proc']));
452
+ if (a.strict) {
453
+ args.push('--size', String(STRICT_TMP_BYTES));
454
+ }
455
+ args.push('--tmpfs', '/tmp');
456
+ for (const b of a.binds) {
457
+ args.push(b.readOnly ? '--ro-bind' : '--bind', b.host, b.at);
458
+ }
459
+ args.push(...(a.extra ?? []));
460
+ args.push('--chdir', a.cwd, '--', ...a.command);
461
+ return args;
462
+ }
463
+ /**
464
+ * The host's root, bound piece by piece: `targets` are mounted later, `omit`
465
+ * is never shown. A directory is opened only for a mount point it lacks;
466
+ * whatever it holds is then bound entry by entry, so this stays rare — under
467
+ * SELinux a bind of something the runtime itself mounted can be refused.
468
+ */
469
+ export function rootArgs(tree, targets, omit) {
470
+ const args = [];
471
+ const within = (path, dir) => dir === '/' ? path !== '/' : path.startsWith(`${dir}/`);
472
+ const open = (dir) => targets.some((t) => within(t, dir) && tree.kind(t) === 'missing');
473
+ const emit = (dir) => {
474
+ for (const name of tree.list(dir).sort()) {
475
+ const path = dir === '/' ? `/${name}` : `${dir}/${name}`;
476
+ if (omit.includes(path)) {
477
+ continue;
478
+ }
479
+ const kind = tree.kind(path);
480
+ if (kind === 'symlink') {
481
+ args.push('--symlink', tree.link(path), path);
482
+ }
483
+ else if (kind === 'dir' && open(path)) {
484
+ args.push('--dir', path);
485
+ emit(path);
486
+ }
487
+ else if (kind !== 'missing') {
488
+ args.push('--ro-bind', path, path);
489
+ }
490
+ }
491
+ };
492
+ emit('/');
493
+ return args;
494
+ }
495
+ /** What is hidden inside a bound directory is covered: an empty tmpfs, or /dev/null. */
496
+ export function hiddenArgs(tree, hidden) {
497
+ const args = [];
498
+ for (const path of hidden) {
499
+ if (path.split('/').length <= 2 || OWN.some((own) => path.startsWith(`${own}/`))) {
500
+ continue;
501
+ }
502
+ const kind = tree.kind(path);
503
+ if (kind === 'dir') {
504
+ args.push('--tmpfs', path);
505
+ }
506
+ else if (kind === 'file') {
507
+ args.push('--ro-bind', '/dev/null', path);
508
+ }
509
+ }
510
+ return args;
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Whether bwrap can isolate anything here
514
+ //
515
+ // Asked once per binary and process. A fresh `/proc` needs the container's own
516
+ // to be unmasked (or gVisor); without one, the container's is bound read-only:
517
+ // the command still has a pid namespace of its own, and, being in a user
518
+ // namespace of its own too, may not read another process's environment or
519
+ // memory — it can only see that the processes exist.
520
+ // ---------------------------------------------------------------------------
521
+ const probes = new Map();
522
+ export function probeBwrap(bwrap, run = runProcess) {
523
+ const bin = bwrap ?? findBwrap();
524
+ let probe = probes.get(bin);
525
+ if (!probe) {
526
+ probe = probeOnce(bin, run);
527
+ probes.set(bin, probe);
528
+ }
529
+ return probe;
530
+ }
531
+ async function probeOnce(bwrap, run) {
532
+ // `--unshare-user` as every command has it: without it bwrap run by root (a container's root)
533
+ // takes itself for privileged and fails where an unprivileged namespace would do.
534
+ const base = [
535
+ '--unshare-user',
536
+ '--ro-bind',
537
+ '/',
538
+ '/',
539
+ '--dev-bind',
540
+ '/dev',
541
+ '/dev',
542
+ '--unshare-pid',
543
+ ];
544
+ const attempt = async (proc) => {
545
+ try {
546
+ return await run(bwrap, [...base, ...proc, '--', '/bin/sh', '-c', 'exit 0'], {
547
+ timeoutMs: 30_000,
548
+ env: { PATH: DEFAULT_PATH },
549
+ });
550
+ }
551
+ catch (err) {
552
+ return err instanceof Error ? err : new Error(String(err));
553
+ }
554
+ };
555
+ const fresh = await attempt(['--proc', '/proc']);
556
+ if (!(fresh instanceof Error) && fresh.code === 0) {
557
+ return { ok: true, bwrap, proc: 'fresh' };
558
+ }
559
+ const bound = await attempt(['--ro-bind', '/proc', '/proc']);
560
+ if (!(bound instanceof Error) && bound.code === 0) {
561
+ return { ok: true, bwrap, proc: 'bind' };
562
+ }
563
+ const error = bound instanceof Error ? bound.message : firstLine(bound.stderr) || `exit ${bound.code}`;
564
+ return { ok: false, bwrap, error, hint: probeHint(error) };
565
+ }
566
+ function probeHint(error) {
567
+ if (/not installed|ENOENT/i.test(error)) {
568
+ return `install bubblewrap, or run in the agent image (${ZENERA_BWRAP})`;
569
+ }
570
+ return ('bwrap needs unprivileged user namespaces: allowed by the container runtime ' +
571
+ '(seccomp, AppArmor) and the kernel (user.max_user_namespaces, ' +
572
+ 'kernel.apparmor_restrict_unprivileged_userns=0); gVisor allows them');
573
+ }
574
+ function findBwrap() {
575
+ if (existsSync(ZENERA_BWRAP)) {
576
+ return ZENERA_BWRAP;
577
+ }
578
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
579
+ if (dir && existsSync(join(dir, 'bwrap'))) {
580
+ return join(dir, 'bwrap');
581
+ }
582
+ }
583
+ return 'bwrap';
584
+ }
585
+ // ---------------------------------------------------------------------------
586
+ // Helpers
587
+ // ---------------------------------------------------------------------------
588
+ /** bwrap's own failures are its exit code 1 and a line of its own; nothing ran. */
589
+ function refuseSetupFailure(res) {
590
+ if (res.code === 1 && res.stdout === '' && /^bwrap: /.test(res.stderr)) {
591
+ throw new SandboxError(`the sandbox could not start the command: ${firstLine(res.stderr)}`);
592
+ }
593
+ }
594
+ async function readWindow(file, first, max) {
595
+ const window = [];
596
+ let lines = 0;
597
+ if (!existsSync(file)) {
598
+ return { lines, window };
599
+ }
600
+ const input = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
601
+ for await (const line of input) {
602
+ lines++;
603
+ if (lines >= first && window.length < max) {
604
+ window.push(line);
605
+ }
606
+ }
607
+ return { lines, window };
608
+ }
609
+ function signalGroup(pid, signal) {
610
+ try {
611
+ process.kill(-pid, signal);
612
+ }
613
+ catch {
614
+ // already gone
615
+ }
616
+ }
617
+ function exitOfSignal(signal) {
618
+ return signal ? 128 + (constants.signals[signal] ?? 0) : 1;
619
+ }
620
+ let imageEnvRead;
621
+ /** The image's own environment, from IMAGE_ENV_FILE; read once. Only string values count. */
622
+ function imageEnv() {
623
+ if (!imageEnvRead) {
624
+ imageEnvRead = {};
625
+ try {
626
+ const parsed = JSON.parse(readFileSync(IMAGE_ENV_FILE, 'utf8'));
627
+ imageEnvRead.env = Object.fromEntries(Object.entries(parsed).filter((e) => typeof e[1] === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(e[0])));
628
+ }
629
+ catch {
630
+ // not the agent image
631
+ }
632
+ }
633
+ return imageEnvRead.env;
634
+ }
635
+ function cgroupNumber(file) {
636
+ try {
637
+ const value = Number(readFileSync(join(CGROUP, file), 'utf8').trim());
638
+ return Number.isFinite(value) ? value : undefined;
639
+ }
640
+ catch {
641
+ return undefined;
642
+ }
643
+ }
644
+ function cgroupLimitMib() {
645
+ const max = cgroupNumber('memory.max');
646
+ return max === undefined ? undefined : Math.round(max / (1024 * 1024));
647
+ }
648
+ function cgroupKills() {
649
+ try {
650
+ const events = readFileSync(join(CGROUP, 'memory.events'), 'utf8');
651
+ const kills = /^oom_kill (\d+)$/m.exec(events)?.[1];
652
+ return kills === undefined ? undefined : Number(kills);
653
+ }
654
+ catch {
655
+ return undefined;
656
+ }
657
+ }
658
+ function firstLine(text) {
659
+ return text.trim().split('\n')[0] ?? '';
660
+ }
661
+ function depth(path) {
662
+ return path.split('/').length;
663
+ }
664
+ function jobId() {
665
+ return `job-${randomBytes(3).toString('hex')}`;
666
+ }
667
+ function resolveSpec(opts) {
668
+ const workdir = opts.workdir ?? WORKSPACE_MOUNT;
669
+ if (!posix.isAbsolute(workdir)) {
670
+ throw new SandboxError(`sandbox workdir must be absolute: ${workdir}`);
671
+ }
672
+ for (const name of [...Object.keys(opts.execEnv ?? {}), ...Object.keys(opts.execPaths ?? {})]) {
673
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
674
+ throw new SandboxError(`not a variable name: ${JSON.stringify(name)}`);
675
+ }
676
+ }
677
+ const { hardening } = sandboxLimits(opts);
678
+ return {
679
+ root: opts.root,
680
+ key: opts.key,
681
+ image: opts.image ?? DEFAULT_SANDBOX_IMAGE,
682
+ build: opts.build,
683
+ network: opts.network ?? (hardening === 'strict' ? 'none' : 'bridge'),
684
+ workdir,
685
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS / 1000,
686
+ hardening,
687
+ user: parseUser(opts.user),
688
+ env: opts.env ?? {},
689
+ secrets: opts.secrets ?? [],
690
+ readOnly: opts.readOnly ?? false,
691
+ mounts: opts.mounts ?? [],
692
+ execEnv: opts.execEnv ?? {},
693
+ execPaths: opts.execPaths ?? {},
694
+ };
695
+ }
696
+ /** Numbers only: inside a user namespace a name means nothing the image's passwd says. */
697
+ function parseUser(user) {
698
+ if (!user) {
699
+ return undefined;
700
+ }
701
+ const m = /^(\d+)(?::(\d+))?$/.exec(user);
702
+ if (!m) {
703
+ throw new SandboxError(`the bwrap sandbox takes a numeric user (uid or uid:gid), not ${JSON.stringify(user)}`);
704
+ }
705
+ return { uid: m[1], gid: m[2] };
706
+ }
707
+ //# sourceMappingURL=bwrap.js.map
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './bwrap.ts';
1
2
  export * from './contract.ts';
2
3
  export * from './fs.ts';
3
4
  export * from './image.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The sandbox: where a session's commands run and the files they see. Depends
2
2
  // on Node alone, so a host, a sandbox service and the agent inside a container
3
3
  // can all stand on the same contract. Contract tests: `@zenera/sandbox/testing`.
4
+ export * from "./bwrap.js";
4
5
  export * from "./contract.js";
5
6
  export * from "./fs.js";
6
7
  export * from "./image.js";
package/dist/mounts.d.ts CHANGED
@@ -17,6 +17,10 @@ export type MountSource =
17
17
  | {
18
18
  kind: 'home';
19
19
  }
20
+ /** the `zenera` libraries, read-only, at /opt/zenera/runtime — one copy for every session */
21
+ | {
22
+ kind: 'runtime';
23
+ }
20
24
  /** one credential file, by the variable that names it */
21
25
  | {
22
26
  kind: 'credential';
package/dist/process.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface ProcOptions {
7
7
  signal?: AbortSignal;
8
8
  /** per stream */
9
9
  maxBytes?: number;
10
+ /** the child's whole environment; this process's when unset */
11
+ env?: NodeJS.ProcessEnv;
10
12
  }
11
13
  export interface ProcResult {
12
14
  code: number;
package/dist/process.js CHANGED
@@ -13,7 +13,11 @@ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) =
13
13
  let truncated = false;
14
14
  let timedOut = false;
15
15
  let done = false;
16
- const child = spawn(bin, [...args], { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
16
+ const child = spawn(bin, [...args], {
17
+ shell: false,
18
+ stdio: ['pipe', 'pipe', 'pipe'],
19
+ env: opts.env,
20
+ });
17
21
  const keep = (into, size, chunk) => {
18
22
  const room = max - size;
19
23
  if (room <= 0) {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@zenera/sandbox",
3
- "version": "1.1.59",
4
- "description": "Where an agent's commands run and the files they see: the Sandbox contract, a podman implementation, filesystem drivers and a contract test suite for other implementations.",
3
+ "version": "1.2.0",
4
+ "description": "Where an agent's commands run and the files they see: the Sandbox contract, podman and bubblewrap implementations, filesystem drivers and a contract test suite for other implementations.",
5
5
  "keywords": [
6
6
  "sandbox",
7
7
  "container",
8
8
  "podman",
9
+ "bubblewrap",
9
10
  "ai-agents",
10
11
  "agent-runtime"
11
12
  ],