kern-sandbox 0.1.1
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 +170 -0
- package/index.d.ts +124 -0
- package/index.js +965 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# kern-sandbox (Node.js / TypeScript)
|
|
2
|
+
|
|
3
|
+
Run LLM/agent-generated code in a fast, **local**, daemonless kernel sandbox, straight from Node.
|
|
4
|
+
|
|
5
|
+
It is a thin, dependency-free wrapper around the [`kern`](https://github.com/getkern/kern) binary:
|
|
6
|
+
a fresh, isolated box per call, network off by default, hard resource caps, and a timeout the binding
|
|
7
|
+
itself enforces. E2B/Firecracker territory, but local and about 1.6 MB, with no cloud, no account, no VM.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const kern = require("kern-sandbox");
|
|
11
|
+
|
|
12
|
+
// one-shot: a throwaway box, network off, hard caps, a timeout the binding enforces
|
|
13
|
+
const r = await kern.runCode("print(sum(range(100)))");
|
|
14
|
+
console.log(r.stdout, r.success); // "4950\n" true
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
TypeScript types ship in the box:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { runCode, withSandbox, Sandbox } from "kern-sandbox";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install kern-sandbox
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You also need the `kern` binary on `PATH` (or point `$KERN_BIN` at it). One line on Linux:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2;
|
|
36
|
+
on macOS, inside a Linux VM. Node 18+.
|
|
37
|
+
|
|
38
|
+
## A session: files persist, processes are ephemeral
|
|
39
|
+
|
|
40
|
+
File state lives in a workspace directory on the host, bind-mounted into every box. Each `runCode`/`run`
|
|
41
|
+
spawns a **fresh** box on that shared workspace, so file state persists but in-memory state does not
|
|
42
|
+
(write to disk for continuity). `withSandbox` opens the session and cleans it up, even on throw:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
|
|
46
|
+
await sbx.writeFile("data.csv", csvBytes);
|
|
47
|
+
const r = await sbx.runCode(
|
|
48
|
+
"import pandas as pd; print(pd.read_csv('data.csv').describe())",
|
|
49
|
+
);
|
|
50
|
+
console.log(r.stdout); // network off, capped, isolated
|
|
51
|
+
const chart = await sbx.readFile("out.png");
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`setup` is the **only** moment the network is on (a separate box that installs deps into the workspace
|
|
56
|
+
and dies); every `runCode` after it is network-off.
|
|
57
|
+
|
|
58
|
+
## Run JavaScript in the box too
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
|
|
62
|
+
image: "node:20-slim",
|
|
63
|
+
language: "node",
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`language` is `"python"` (default), `"bash"`, or `"node"`. Match the image to the language.
|
|
68
|
+
|
|
69
|
+
## The result
|
|
70
|
+
|
|
71
|
+
`runCode`/`run` resolve to an `ExecutionResult`:
|
|
72
|
+
|
|
73
|
+
| field | meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `stdout`, `stderr` | captured output (each capped at `maxOutputBytes`) |
|
|
76
|
+
| `exitCode` | the process exit code |
|
|
77
|
+
| `success` | `true` iff `exitCode === 0` **and** no sandbox fault |
|
|
78
|
+
| `fault` | a sandbox event, or `null`. `{ type, message }` |
|
|
79
|
+
| `files` | files created/modified in the workspace this call |
|
|
80
|
+
| `truncated` | output hit the cap and overflow was discarded |
|
|
81
|
+
|
|
82
|
+
A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it is a normal result.
|
|
83
|
+
`fault` is only set when the **sandbox** acted:
|
|
84
|
+
|
|
85
|
+
| `fault.type` | when |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `timeout` | the call exceeded `timeoutS`; the binding killed the box |
|
|
88
|
+
| `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
|
|
89
|
+
| `killed` | the box was SIGKILLed, most often the cgroup OOM-killer |
|
|
90
|
+
| `startup_failed` | kern could not start the box (bad image, pull error, ...) |
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const r = await kern.runCode("while True: pass", { timeoutS: 5 });
|
|
94
|
+
r.success; // false
|
|
95
|
+
r.fault.type; // "timeout"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Safe by default
|
|
99
|
+
|
|
100
|
+
Every relaxing option says so in its name or docs:
|
|
101
|
+
|
|
102
|
+
- **network off** unless `network: true` (session-level, explicit).
|
|
103
|
+
- **hard caps**: `memoryMb` (512), `pids` (256), optional `cpus`. Enforced by cgroup v2.
|
|
104
|
+
- **timeout owned by the binding**: `timeoutS` (30) is a real deadline; the binding kills the box (and
|
|
105
|
+
its process group), so a `timeout` fault is a fact, not a guess.
|
|
106
|
+
- **output bounded**: `maxOutputBytes` (64 MiB each) so a flooding box cannot exhaust host RAM.
|
|
107
|
+
- **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
|
|
108
|
+
line, so a credential in `env` does not leak into `ps`.
|
|
109
|
+
- **mounts refused**: sensitive host sources (`/`, `/etc`, `/root`, `/proc`, `/sys`, `/dev`, the docker
|
|
110
|
+
socket, `$HOME`) and escaping targets are refused even when asked.
|
|
111
|
+
- **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes and open the final component
|
|
112
|
+
`O_NOFOLLOW`, so a symlink the box plants cannot redirect host I/O outside the workspace.
|
|
113
|
+
|
|
114
|
+
### Options
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
new Sandbox({
|
|
118
|
+
image, // default "python:3.12-slim"
|
|
119
|
+
setup, // one-time, network-on, e.g. "pip install pandas"
|
|
120
|
+
workspace, // host dir to persist; omit for a temp dir deleted on close()
|
|
121
|
+
memoryMb, // default 512
|
|
122
|
+
cpus, // default null (uncapped)
|
|
123
|
+
pids, // default 256
|
|
124
|
+
timeoutS, // default 30, MANDATORY per-call deadline
|
|
125
|
+
network, // default false (RELAXES ISOLATION)
|
|
126
|
+
mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
|
|
127
|
+
profiles, // reusable kern.toml profiles: ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]
|
|
128
|
+
env, // { KEY: "value" }
|
|
129
|
+
maxOutputBytes, // default 64 MiB
|
|
130
|
+
enforceLimits, // default true (systemd scope, ~6 ms); false = best-effort, ~3 ms
|
|
131
|
+
depsReadonly, // default false
|
|
132
|
+
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
133
|
+
onStderr, // (chunk: Buffer) => void, live stderr streaming
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Charts, live output, and checkpoints
|
|
138
|
+
|
|
139
|
+
**Charts / artifacts (the "code interpreter" pattern).** No Jupyter kernel: have the code WRITE the
|
|
140
|
+
artifact to the workspace, then read it back with `readFile`.
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
await kern.withSandbox({ setup: "pip install matplotlib" }, async (sbx) => {
|
|
144
|
+
await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
145
|
+
"import matplotlib.pyplot as plt; plt.plot([1,4,9]); plt.savefig('chart.png')");
|
|
146
|
+
const png = await sbx.readFile("chart.png"); // Buffer, ready to return to the model / user
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
|
|
151
|
+
best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
|
|
152
|
+
(the full capped output is always in `result.stdout`).
|
|
153
|
+
|
|
154
|
+
**Checkpoints.** `sbx.snapshot(dest)` writes a portable `.tar.gz` of the workspace (a FILESYSTEM
|
|
155
|
+
checkpoint, not memory); `sbx.restore(src)` extracts it back, refusing absolute / `..` / symlink
|
|
156
|
+
members. Interoperable with `tar` and the Python binding (both write plain USTAR, so a workspace path
|
|
157
|
+
must be under 100 bytes). The Node path uses a hand-rolled tar reader,
|
|
158
|
+
so while it is new it is **opt-in**: set `KERN_SANDBOX_SNAPSHOT=1` to enable it (it fails closed with a
|
|
159
|
+
clear error otherwise). The Python binding uses the stdlib `tarfile` and has no such gate.
|
|
160
|
+
|
|
161
|
+
## Honest threat model
|
|
162
|
+
|
|
163
|
+
kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code (CI, dev, edge, your
|
|
164
|
+
agents' code). Its seccomp filter is a **denylist**: right for semi-trusted agent code, **not** a hard
|
|
165
|
+
boundary against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker) or
|
|
166
|
+
gVisor. See the project's [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
[Apache-2.0](https://github.com/getkern/kern/blob/main/LICENSE).
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Type definitions for kern-sandbox
|
|
2
|
+
// Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
|
|
3
|
+
|
|
4
|
+
/** What stopped the code at the SANDBOX level. Reported as data on a result, never thrown. */
|
|
5
|
+
export type SandboxFaultType = "timeout" | "escape_blocked" | "killed" | "startup_failed";
|
|
6
|
+
|
|
7
|
+
export interface SandboxFault {
|
|
8
|
+
type: SandboxFaultType;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A file in the workspace and how a step touched it. */
|
|
13
|
+
export interface FileInfo {
|
|
14
|
+
/** workspace-relative path */
|
|
15
|
+
path: string;
|
|
16
|
+
size: number;
|
|
17
|
+
change: "created" | "modified";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the sandbox act";
|
|
21
|
+
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
22
|
+
export class ExecutionResult {
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
exitCode: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
/** null iff the sandbox did nothing: any non-zero exit is then the user's code, not a sandbox fault. */
|
|
28
|
+
fault: SandboxFault | null;
|
|
29
|
+
files: FileInfo[];
|
|
30
|
+
/** stdout/stderr hit the capture cap and overflow was discarded. */
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
33
|
+
readonly success: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, or `kern` not installed. */
|
|
37
|
+
export class SandboxError extends Error {}
|
|
38
|
+
/** A requested host mount was refused as unsafe (sensitive source, or a relative/escaping path). */
|
|
39
|
+
export class MountRefused extends SandboxError {}
|
|
40
|
+
|
|
41
|
+
export type MountSpec = string | [target: string, mode: "ro" | "rw"];
|
|
42
|
+
|
|
43
|
+
export interface SandboxOptions {
|
|
44
|
+
/** OCI image the box runs from. Default: "python:3.12-slim". */
|
|
45
|
+
image?: string;
|
|
46
|
+
/** Shell command run ONCE at open() in a NETWORK-ENABLED setup box (e.g. "pip install pandas"). */
|
|
47
|
+
setup?: string;
|
|
48
|
+
/** Host dir to persist as the workspace. Omit -> a temp dir, created on open() and deleted on close(). */
|
|
49
|
+
workspace?: string;
|
|
50
|
+
/** RAM cap in MiB (kern --memory). Default 512. null = uncapped default. */
|
|
51
|
+
memoryMb?: number | null;
|
|
52
|
+
/** CPU cap in cores (kern --cpus). null (default) = uncapped. */
|
|
53
|
+
cpus?: number | null;
|
|
54
|
+
/** Task/fork-bomb ceiling (kern --pids-limit). Default 256. */
|
|
55
|
+
pids?: number | null;
|
|
56
|
+
/** MANDATORY per-call wall-clock limit in seconds. The binding owns this deadline. Default 30. */
|
|
57
|
+
timeoutS?: number;
|
|
58
|
+
/** RELAXES ISOLATION. true shares the host network for every runCode. Default false. */
|
|
59
|
+
network?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Restrict runCode/run to a DOMAIN ALLOWLIST instead of all-or-nothing, e.g. ["pypi.org",
|
|
62
|
+
* "files.pythonhosted.org"]. The box runs in an isolated network namespace and reaches the internet
|
|
63
|
+
* only through kern's filtering proxy, which permits just these domains. Mutually exclusive with
|
|
64
|
+
* network:true; the setup box keeps full network to install deps, the allowlist governs the run phase.
|
|
65
|
+
*/
|
|
66
|
+
egressAllow?: string[];
|
|
67
|
+
/** Extra host->box binds: { hostSrc: boxTarget } or { src: [target, "ro"] }. Sensitive sources refused. */
|
|
68
|
+
mounts?: Record<string, MountSpec>;
|
|
69
|
+
/**
|
|
70
|
+
* kern resource profiles to attach, e.g. ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]. Each names a
|
|
71
|
+
* [[vcpu]]/[[vgpio]]/[[vdisk]] block in your ~/.config/kern/kern.toml: a CPU+memory slice, a specific
|
|
72
|
+
* GPIO/I2C/SPI device set (the only way to grant the box hardware), or a size-capped scratch disk.
|
|
73
|
+
* Tokens are strictly validated (prefix + alphanumeric name) so an entry can never smuggle a flag.
|
|
74
|
+
*/
|
|
75
|
+
profiles?: string[];
|
|
76
|
+
/** Extra environment variables for the workload (passed via a private 0600 file, not argv). */
|
|
77
|
+
env?: Record<string, string>;
|
|
78
|
+
/** Cap on captured stdout/stderr EACH, in bytes. Default 64 MiB. */
|
|
79
|
+
maxOutputBytes?: number;
|
|
80
|
+
/** true (default) hard-enforces caps via a systemd scope (~6 ms start); false = best-effort (~3 ms). */
|
|
81
|
+
enforceLimits?: boolean;
|
|
82
|
+
/** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default false. */
|
|
83
|
+
depsReadonly?: boolean;
|
|
84
|
+
/** Called with each stdout Buffer chunk as it arrives (live streaming). The full capped output is
|
|
85
|
+
* still captured in the result, so you can stream AND read result.stdout. */
|
|
86
|
+
onStdout?: (chunk: Buffer) => void;
|
|
87
|
+
/** Called with each stderr Buffer chunk as it arrives. */
|
|
88
|
+
onStderr?: (chunk: Buffer) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Language = "python" | "bash" | "node";
|
|
92
|
+
|
|
93
|
+
/** A configured kernel sandbox. FILE state persists across runCode/run in a workspace on disk; each
|
|
94
|
+
* call runs in a FRESH ephemeral box. Safe by default; every relaxing option says so. */
|
|
95
|
+
export class Sandbox {
|
|
96
|
+
constructor(opts?: SandboxOptions);
|
|
97
|
+
/** Create/validate the workspace and run `setup`. Call before runCode/run/writeFile. */
|
|
98
|
+
open(): Promise<this>;
|
|
99
|
+
/** Delete the workspace iff we created it. Idempotent. */
|
|
100
|
+
close(): Promise<void>;
|
|
101
|
+
/** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not. */
|
|
102
|
+
runCode(code: string, opts?: { language?: Language }): Promise<ExecutionResult>;
|
|
103
|
+
/** Run an argv ARRAY (never a shell string) in a fresh box. */
|
|
104
|
+
run(command: string[]): Promise<ExecutionResult>;
|
|
105
|
+
/** Write data to a workspace-relative path (host-direct, O_NOFOLLOW on the final component). */
|
|
106
|
+
writeFile(path: string, data: Buffer | string): Promise<void>;
|
|
107
|
+
/** Read a workspace-relative path (host-direct, O_NOFOLLOW). */
|
|
108
|
+
readFile(path: string): Promise<Buffer>;
|
|
109
|
+
/** Write a gzip tar of the whole workspace to `dest`, a portable filesystem checkpoint (NOT memory). */
|
|
110
|
+
snapshot(dest: string): void;
|
|
111
|
+
/** Extract a snapshot (from snapshot()) into the workspace, safely (rejects symlink/.. /absolute members). */
|
|
112
|
+
restore(src: string): void;
|
|
113
|
+
/** List regular files under the workspace (excludes .deps). */
|
|
114
|
+
listFiles(subdir?: string): Promise<FileInfo[]>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Open a Sandbox, run `fn(sandbox)`, and close it even if `fn` throws. The session helper. */
|
|
118
|
+
export function withSandbox<T>(fn: (sandbox: Sandbox) => Promise<T>): Promise<T>;
|
|
119
|
+
export function withSandbox<T>(opts: SandboxOptions, fn: (sandbox: Sandbox) => Promise<T>): Promise<T>;
|
|
120
|
+
|
|
121
|
+
/** One-shot: run `code` in a throwaway session (workspace created and deleted). */
|
|
122
|
+
export function runCode(code: string, opts?: SandboxOptions & { language?: Language }): Promise<ExecutionResult>;
|
|
123
|
+
|
|
124
|
+
export const version: string;
|
package/index.js
ADDED
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kern-sandbox - run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
|
|
3
|
+
*
|
|
4
|
+
* const kern = require('kern-sandbox');
|
|
5
|
+
*
|
|
6
|
+
* // one-shot (a throwaway session under the hood)
|
|
7
|
+
* const r = await kern.runCode("console.log(1 + 1)", { language: "node" });
|
|
8
|
+
* console.log(r.stdout, r.success);
|
|
9
|
+
*
|
|
10
|
+
* // a session: FILE state persists across steps; processes are ephemeral
|
|
11
|
+
* await kern.withSandbox({ setup: "pip install pandas" }, async (sbx) => {
|
|
12
|
+
* await sbx.writeFile("data.csv", csvBytes);
|
|
13
|
+
* const r = await sbx.runCode("import pandas as pd; print(pd.read_csv('data.csv').shape)");
|
|
14
|
+
* const png = await sbx.readFile("out.png");
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Design mirrors the Python binding exactly:
|
|
18
|
+
* - FILE state persists via a workspace DIRECTORY on the host, bind-mounted into each box.
|
|
19
|
+
* PROCESSES are ephemeral: every runCode()/run() spawns a FRESH box on that shared workspace.
|
|
20
|
+
* In-memory state does NOT survive between calls; write to disk for continuity.
|
|
21
|
+
* - I/O is HOST-DIRECT: single-uid maps box-root to the host user, so files the box creates are
|
|
22
|
+
* host-owned; writeFile/readFile are plain host filesystem I/O.
|
|
23
|
+
* - The BINDING owns the timeout (it kills the box), so a `timeout` fault is a known fact.
|
|
24
|
+
*
|
|
25
|
+
* Threat model (honest): kern is a KERNEL-BOUNDARY sandbox for YOUR OWN or SEMI-TRUSTED code. seccomp
|
|
26
|
+
* is a DENYLIST - suitable for semi-trusted agent code, NOT a hard boundary against deliberately
|
|
27
|
+
* hostile multi-tenant code (for that: a microVM / gVisor).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
"use strict";
|
|
31
|
+
|
|
32
|
+
const fs = require("fs");
|
|
33
|
+
const os = require("os");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
const crypto = require("crypto");
|
|
36
|
+
const zlib = require("zlib");
|
|
37
|
+
const { spawn, spawnSync } = require("child_process");
|
|
38
|
+
|
|
39
|
+
const VERSION = "0.1.1";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
42
|
+
const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
|
|
43
|
+
const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
|
|
44
|
+
const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
|
|
45
|
+
const INLINE_CODE_MAX = 128 * 1024; // above this, pass code via a file instead of argv (ARG_MAX guard)
|
|
46
|
+
|
|
47
|
+
// Signal-derived exit codes (128 + signum) we classify.
|
|
48
|
+
const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
|
|
49
|
+
const EXIT_SIGSYS = 159; // SIGSYS: a seccomp-denied syscall = a blocked escape attempt
|
|
50
|
+
const EXIT_SIGTERM = 143; // SIGTERM: kern's --timeout backstop reaping the box
|
|
51
|
+
|
|
52
|
+
// Host paths a `-v` mount must never target - mounting the host's real root/config/secrets into a
|
|
53
|
+
// sandbox defeats the point; the docker socket is the classic escape. Refused even when asked.
|
|
54
|
+
const REFUSED_MOUNT_SOURCES = new Set([
|
|
55
|
+
"/",
|
|
56
|
+
"/etc",
|
|
57
|
+
"/root",
|
|
58
|
+
"/boot",
|
|
59
|
+
"/proc",
|
|
60
|
+
"/sys",
|
|
61
|
+
"/dev",
|
|
62
|
+
"/var/run/docker.sock",
|
|
63
|
+
"/run/docker.sock",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, or `kern` not installed. Runtime
|
|
67
|
+
* sandbox events (timeout, blocked escape, OOM-kill) are NOT thrown - they are data in result.fault. */
|
|
68
|
+
class SandboxError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "SandboxError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A requested host mount was refused as unsafe (sensitive source, or a relative/escaping path). */
|
|
76
|
+
class MountRefused extends SandboxError {
|
|
77
|
+
constructor(message) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "MountRefused";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the SANDBOX act";
|
|
84
|
+
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
85
|
+
class ExecutionResult {
|
|
86
|
+
constructor({ stdout, stderr, exitCode, durationMs, fault, files, truncated }) {
|
|
87
|
+
this.stdout = stdout;
|
|
88
|
+
this.stderr = stderr;
|
|
89
|
+
this.exitCode = exitCode;
|
|
90
|
+
this.durationMs = durationMs;
|
|
91
|
+
/** @type {{type: string, message: string} | null} */
|
|
92
|
+
this.fault = fault || null;
|
|
93
|
+
this.files = files || [];
|
|
94
|
+
this.truncated = !!truncated;
|
|
95
|
+
}
|
|
96
|
+
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
97
|
+
get success() {
|
|
98
|
+
return this.exitCode === 0 && this.fault === null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sandboxFault(type, message) {
|
|
103
|
+
return { type, message };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Locate `kern`: $KERN_BIN if set, else the first `kern` on $PATH. */
|
|
107
|
+
function findKern() {
|
|
108
|
+
const env = process.env.KERN_BIN;
|
|
109
|
+
if (env) {
|
|
110
|
+
try {
|
|
111
|
+
fs.accessSync(env, fs.constants.X_OK);
|
|
112
|
+
if (!fs.statSync(env).isFile()) throw new Error("not a file");
|
|
113
|
+
} catch {
|
|
114
|
+
throw new SandboxError(`$KERN_BIN='${env}' is not an executable file`);
|
|
115
|
+
}
|
|
116
|
+
return env;
|
|
117
|
+
}
|
|
118
|
+
const exts = [""];
|
|
119
|
+
const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
120
|
+
for (const d of dirs) {
|
|
121
|
+
for (const ext of exts) {
|
|
122
|
+
const cand = path.join(d, "kern" + ext);
|
|
123
|
+
try {
|
|
124
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
125
|
+
if (fs.statSync(cand).isFile()) return cand;
|
|
126
|
+
} catch {
|
|
127
|
+
/* keep looking */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new SandboxError(
|
|
132
|
+
"the `kern` binary was not found on PATH - install it " +
|
|
133
|
+
"(https://github.com/getkern/kern) or set $KERN_BIN",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Validate one host->box mount; refuse unsafe sources/targets. Returns [absRealSource, target]. */
|
|
138
|
+
function validateMount(source, target) {
|
|
139
|
+
if (typeof target !== "string" || !target.startsWith("/"))
|
|
140
|
+
throw new MountRefused(`mount target must be an absolute path in the box, got ${JSON.stringify(target)}`);
|
|
141
|
+
if (target.split("/").some((c) => c === ".."))
|
|
142
|
+
throw new MountRefused(`mount target must not contain '..': ${JSON.stringify(target)}`);
|
|
143
|
+
const normTarget = "/" + target.split("/").filter((c) => c && c !== ".").join("/");
|
|
144
|
+
if (["/", "/proc", "/sys", "/dev"].includes(normTarget))
|
|
145
|
+
throw new MountRefused(`cannot mount over the box essential mount ${JSON.stringify(normTarget)}`);
|
|
146
|
+
if (typeof source !== "string" || !path.isAbsolute(source))
|
|
147
|
+
throw new MountRefused(`mount source must be an absolute host path, got ${JSON.stringify(source)}`);
|
|
148
|
+
let real;
|
|
149
|
+
try {
|
|
150
|
+
real = fs.realpathSync(source); // resolve symlinks BEFORE the sensitive-set check
|
|
151
|
+
} catch {
|
|
152
|
+
throw new MountRefused(`mount source does not exist: ${JSON.stringify(source)}`);
|
|
153
|
+
}
|
|
154
|
+
const home = (() => {
|
|
155
|
+
try {
|
|
156
|
+
return fs.realpathSync(os.homedir());
|
|
157
|
+
} catch {
|
|
158
|
+
return os.homedir();
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
if (REFUSED_MOUNT_SOURCES.has(real) || real === home)
|
|
162
|
+
throw new MountRefused(
|
|
163
|
+
`refusing to mount the sensitive host path ${JSON.stringify(real)} into a sandbox ` +
|
|
164
|
+
"(this would defeat the isolation)",
|
|
165
|
+
);
|
|
166
|
+
return [real, target];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A resource-profile token (`vcpu:`/`vgpio:`/`vdisk:` + a named profile from the user's kern.toml).
|
|
170
|
+
// ANCHORED and charset-restricted: the token is passed as a POSITIONAL arg to `kern box`, so it must be
|
|
171
|
+
// EXACTLY a known prefix plus a safe name. This is what stops a caller (or agent-chosen value) from
|
|
172
|
+
// smuggling another flag through the profile list ("--net", "-v /etc:/etc", "vgpu:x", a name with a
|
|
173
|
+
// space / `=` / `/` / leading dash). The three prefixes mirror `config::classify` in kern.
|
|
174
|
+
const PROFILE_RE = /^(?:vcpu|vgpio|vdisk):[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
175
|
+
|
|
176
|
+
/** Validate one `vcpu:`/`vgpio:`/`vdisk:NAME` resource-profile token before it reaches the argv. */
|
|
177
|
+
function validateProfile(token) {
|
|
178
|
+
if (typeof token !== "string" || !PROFILE_RE.test(token))
|
|
179
|
+
throw new SandboxError(
|
|
180
|
+
`invalid resource profile ${JSON.stringify(token)}: expected 'vcpu:NAME', 'vgpio:NAME' or ` +
|
|
181
|
+
"'vdisk:NAME' with an alphanumeric profile name (the profile must be defined in your kern.toml)",
|
|
182
|
+
);
|
|
183
|
+
return token;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A public DNS domain for the egress allowlist. LDH labels, at least one dot (an FQDN), alphabetic TLD.
|
|
187
|
+
// Restrictive on purpose: the value is comma-joined and handed to `kern box --egress-allow`, so it must
|
|
188
|
+
// not carry a comma, scheme, path, port, wildcard or whitespace that could change the argument. kern
|
|
189
|
+
// re-validates and SSRF-checks the resolved IPs; this is the binding's first gate.
|
|
190
|
+
const DOMAIN_RE = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
|
191
|
+
|
|
192
|
+
/** Validate one egress-allowlist domain (an FQDN like "pypi.org") before it reaches the argv. */
|
|
193
|
+
function validateDomain(domain) {
|
|
194
|
+
if (typeof domain !== "string" || !DOMAIN_RE.test(domain))
|
|
195
|
+
throw new SandboxError(
|
|
196
|
+
`invalid egress domain ${JSON.stringify(domain)}: expected a bare hostname like 'pypi.org' ` +
|
|
197
|
+
"(no scheme, port, path, wildcard or spaces)",
|
|
198
|
+
);
|
|
199
|
+
return domain;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Map a Node close event {code, signal} to a unix-style rc (128 + signum for a signal). */
|
|
203
|
+
function toRc(code, signal) {
|
|
204
|
+
if (typeof code === "number") return code;
|
|
205
|
+
const table = { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGSEGV: 11, SIGTERM: 15, SIGSYS: 31 };
|
|
206
|
+
if (signal && table[signal] !== undefined) return 128 + table[signal];
|
|
207
|
+
return -1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True iff kern (the PARENT, before the box exists) failed to start the box. Anchored on kern's OWN
|
|
211
|
+
* diagnostic prefixes so the workload can't forge them by writing the marker to its own stderr. */
|
|
212
|
+
function looksLikeStartupFailure(stderr) {
|
|
213
|
+
const markers = ["kern:", "error: pull:", "error: sandbox:", "error: box:", "error: oci:", "error: image:"];
|
|
214
|
+
for (const line of stderr.split("\n")) {
|
|
215
|
+
const s = line.replace(/^\s+/, "");
|
|
216
|
+
if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function uniqueName() {
|
|
222
|
+
return "jssbx-" + crypto.randomBytes(6).toString("hex");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Drain a readable stream into a bounded buffer: keep at most `cap` bytes but KEEP reading past it
|
|
226
|
+
* (discarding overflow) so a flooding box never blocks on a full pipe. RAM is bounded to `cap`. */
|
|
227
|
+
function cappedCollector(stream, cap, onData) {
|
|
228
|
+
const chunks = [];
|
|
229
|
+
let len = 0;
|
|
230
|
+
const state = { truncated: false };
|
|
231
|
+
stream.on("data", (chunk) => {
|
|
232
|
+
if (onData) {
|
|
233
|
+
// stream every chunk live; a callback throw must never break the drain (the box would then
|
|
234
|
+
// block on a full pipe), so swallow it, the buffered result still returns.
|
|
235
|
+
try {
|
|
236
|
+
onData(chunk);
|
|
237
|
+
} catch {
|
|
238
|
+
/* user callback error ignored on purpose */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (len < cap) {
|
|
242
|
+
const room = cap - len;
|
|
243
|
+
if (chunk.length <= room) {
|
|
244
|
+
chunks.push(chunk);
|
|
245
|
+
len += chunk.length;
|
|
246
|
+
} else {
|
|
247
|
+
chunks.push(chunk.subarray(0, room));
|
|
248
|
+
len = cap;
|
|
249
|
+
state.truncated = true;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
state.truncated = true;
|
|
253
|
+
}
|
|
254
|
+
// never pause: keep draining so the box can't block on a full pipe
|
|
255
|
+
});
|
|
256
|
+
stream.on("error", () => {});
|
|
257
|
+
state.buffer = () => Buffer.concat(chunks);
|
|
258
|
+
return state;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Minimal ustar (POSIX tar) over gzip, for workspace snapshots -------------------------------
|
|
262
|
+
// Dependency-free (Node's zlib does the gzip) and interoperable with `tar tzf` and the Python binding:
|
|
263
|
+
// a snapshot is a real .tar.gz. Only regular files are written; on restore only files/dirs are accepted
|
|
264
|
+
// (symlinks, devices, hardlinks and any absolute or `..`-escaping name are refused), and the final
|
|
265
|
+
// component is opened O_NOFOLLOW, so a hostile archive can never write outside the workspace.
|
|
266
|
+
|
|
267
|
+
function tarWriteFile(out, name, content) {
|
|
268
|
+
if (Buffer.byteLength(name) > 100)
|
|
269
|
+
throw new SandboxError(`snapshot: path too long for the tar format (>100 bytes): ${name}`);
|
|
270
|
+
const h = Buffer.alloc(512);
|
|
271
|
+
h.write(name, 0, 100, "utf8");
|
|
272
|
+
h.write("0000644\0", 100, 8); // mode
|
|
273
|
+
h.write("0000000\0", 108, 8); // uid
|
|
274
|
+
h.write("0000000\0", 116, 8); // gid
|
|
275
|
+
h.write(content.length.toString(8).padStart(11, "0") + "\0", 124, 12); // size (octal)
|
|
276
|
+
h.write("00000000000\0", 136, 12); // mtime 0 (deterministic)
|
|
277
|
+
h.write(" ", 148, 8); // checksum field = 8 spaces while summing
|
|
278
|
+
h.write("0", 156, 1); // typeflag '0' = regular file
|
|
279
|
+
h.write("ustar\0", 257, 6); // magic
|
|
280
|
+
h.write("00", 263, 2); // version
|
|
281
|
+
let sum = 0;
|
|
282
|
+
for (const b of h) sum += b;
|
|
283
|
+
h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8); // checksum: 6 octal digits, NUL, space
|
|
284
|
+
out.push(h, content);
|
|
285
|
+
const pad = (512 - (content.length % 512)) % 512;
|
|
286
|
+
if (pad) out.push(Buffer.alloc(pad));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function tarCollect(dir, base, skip, out) {
|
|
290
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
291
|
+
const abs = path.join(dir, entry);
|
|
292
|
+
const st = fs.lstatSync(abs);
|
|
293
|
+
if (st.isSymbolicLink()) continue; // never archive a symlink
|
|
294
|
+
if (st.isDirectory()) tarCollect(abs, base, skip, out);
|
|
295
|
+
else if (st.isFile()) {
|
|
296
|
+
const rel = path.relative(base, abs);
|
|
297
|
+
if (rel === skip) continue; // our private --env-file, not user state
|
|
298
|
+
tarWriteFile(out, rel.split(path.sep).join("/"), fs.readFileSync(abs));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function tarPack(base, skip) {
|
|
304
|
+
const out = [];
|
|
305
|
+
tarCollect(base, base, skip, out);
|
|
306
|
+
out.push(Buffer.alloc(1024)); // two zero blocks = end of archive
|
|
307
|
+
// level 1: a local checkpoint is often large or already-compressed; level 1 is several times faster
|
|
308
|
+
// than the default with a negligible size penalty. Speed over ratio here.
|
|
309
|
+
return zlib.gzipSync(Buffer.concat(out), { level: 1 });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function tarParse(gz) {
|
|
313
|
+
// Cap the inflated size so a tiny gzip bomb can't force a huge allocation before we even vet members.
|
|
314
|
+
const buf = zlib.gunzipSync(gz, { maxOutputLength: 1024 * 1024 * 1024 });
|
|
315
|
+
const members = [];
|
|
316
|
+
let off = 0;
|
|
317
|
+
while (off + 512 <= buf.length) {
|
|
318
|
+
const h = buf.subarray(off, off + 512);
|
|
319
|
+
if (h.every((b) => b === 0)) break; // end-of-archive zero block
|
|
320
|
+
// Verify the ustar header checksum (sum of all header bytes with the 8-byte checksum field taken as
|
|
321
|
+
// spaces): a single corrupt header field is rejected wholesale, before the per-field vetting runs.
|
|
322
|
+
const stored = parseInt(h.toString("utf8", 148, 156).replace(/\0.*$/s, "").trim(), 8);
|
|
323
|
+
let ck = 0;
|
|
324
|
+
for (let i = 0; i < 512; i++) ck += i >= 148 && i < 156 ? 0x20 : h[i];
|
|
325
|
+
if (stored !== ck) throw new SandboxError("malformed snapshot: bad header checksum");
|
|
326
|
+
// Strip a trailing slash (the ustar dir convention "d/"): otherwise path.join keeps it, and a
|
|
327
|
+
// trailing slash makes lstat FOLLOW a planted symlink ("d/" resolves the link to its target dir)
|
|
328
|
+
// instead of seeing the link, which would defeat the symlink-vet on a dir member.
|
|
329
|
+
const name = h.toString("utf8", 0, 100).replace(/\0.*$/s, "").replace(/\/+$/, "");
|
|
330
|
+
// Size is octal ASCII by spec; reject anything else rather than let parseInt guess ("12x" -> 10).
|
|
331
|
+
// This also makes a negative size impossible (no `-` in the field), closing the spin-forever case.
|
|
332
|
+
const sizeField = h.toString("utf8", 124, 136).replace(/\0.*$/s, "").trim();
|
|
333
|
+
if (!/^[0-7]*$/.test(sizeField)) throw new SandboxError("malformed snapshot: non-octal member size");
|
|
334
|
+
const size = parseInt(sizeField, 8) || 0;
|
|
335
|
+
const flag = String.fromCharCode(h[156]);
|
|
336
|
+
off += 512;
|
|
337
|
+
const type = flag === "0" || flag === "\0" ? "file" : flag === "5" ? "dir" : "other";
|
|
338
|
+
// A dir/other member carries no content in ustar; a non-zero size there is malformed, so reject it
|
|
339
|
+
// rather than silently ignore it (reject-not-guess).
|
|
340
|
+
if (type !== "file" && size !== 0)
|
|
341
|
+
throw new SandboxError("malformed snapshot: non-file member with a non-zero size");
|
|
342
|
+
// Refuse a member claiming more bytes than remain: reject the malformed archive instead of silently
|
|
343
|
+
// truncating the restored file (subarray would clamp to the buffer end).
|
|
344
|
+
if (type === "file" && off + size > buf.length)
|
|
345
|
+
throw new SandboxError("malformed snapshot: member size exceeds archive");
|
|
346
|
+
const content = type === "file" ? buf.subarray(off, off + size) : Buffer.alloc(0);
|
|
347
|
+
members.push({ name, type, content });
|
|
348
|
+
off += Math.ceil(size / 512) * 512;
|
|
349
|
+
}
|
|
350
|
+
return members;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
class Sandbox {
|
|
354
|
+
/**
|
|
355
|
+
* @param {object} [opts]
|
|
356
|
+
* @param {string} [opts.image] OCI image the box runs from. Default a small Python image.
|
|
357
|
+
* @param {string} [opts.setup] shell command run ONCE at open() in a NETWORK-ENABLED box.
|
|
358
|
+
* @param {string} [opts.workspace] host dir to persist as the workspace. null -> a temp dir,
|
|
359
|
+
* created on open() and DELETED on close().
|
|
360
|
+
* @param {number|null} [opts.memoryMb] RAM cap (kern --memory). Default 512.
|
|
361
|
+
* @param {number|null} [opts.cpus] CPU cap in cores; null = uncapped.
|
|
362
|
+
* @param {number|null} [opts.pids] task/fork-bomb ceiling. Default 256.
|
|
363
|
+
* @param {number} [opts.timeoutS] MANDATORY per-call wall-clock limit (binding-owned). Default 30.
|
|
364
|
+
* @param {boolean} [opts.network] RELAXES ISOLATION. true shares the host network. Default false.
|
|
365
|
+
* @param {string[]} [opts.egressAllow] restrict runCode/run to a DOMAIN ALLOWLIST, e.g. ["pypi.org"]; isolated netns + kern's filtering proxy. Mutually exclusive with network:true.
|
|
366
|
+
* @param {Object<string, string|[string,string]>} [opts.mounts] extra host->box binds. Sensitive refused.
|
|
367
|
+
* @param {string[]} [opts.profiles] kern resource profiles to attach, e.g. ["vcpu:heavy","vgpio:leds","vdisk:scratch"]; each names a block in your kern.toml. Strictly validated.
|
|
368
|
+
* @param {Object<string,string>} [opts.env] extra environment for the workload.
|
|
369
|
+
* @param {number} [opts.maxOutputBytes] cap on captured stdout/stderr EACH. Default 64 MiB.
|
|
370
|
+
* @param {boolean} [opts.enforceLimits] true (default) hard-enforces caps via a systemd scope.
|
|
371
|
+
* @param {boolean} [opts.depsReadonly] mount setup= deps read-only for runCode (block poisoning).
|
|
372
|
+
*/
|
|
373
|
+
constructor(opts = {}) {
|
|
374
|
+
this.image = opts.image ?? DEFAULT_IMAGE;
|
|
375
|
+
this.setup = opts.setup ?? null;
|
|
376
|
+
this.workspace = opts.workspace ?? null;
|
|
377
|
+
this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
|
|
378
|
+
this.cpus = opts.cpus ?? null;
|
|
379
|
+
this.pids = opts.pids === undefined ? 256 : opts.pids;
|
|
380
|
+
this.timeoutS = opts.timeoutS ?? 30;
|
|
381
|
+
this.network = opts.network ?? false;
|
|
382
|
+
this.egressAllow = opts.egressAllow ?? null;
|
|
383
|
+
this.mounts = opts.mounts ?? null;
|
|
384
|
+
this.profiles = opts.profiles ?? null;
|
|
385
|
+
this.env = opts.env ?? null;
|
|
386
|
+
this.maxOutputBytes = opts.maxOutputBytes ?? 64 * 1024 * 1024;
|
|
387
|
+
// live output callbacks: called with each Buffer chunk as it arrives. The full capped output is
|
|
388
|
+
// still captured in the result, so you can stream AND read result.stdout.
|
|
389
|
+
this.onStdout = opts.onStdout ?? null;
|
|
390
|
+
this.onStderr = opts.onStderr ?? null;
|
|
391
|
+
this.enforceLimits = opts.enforceLimits ?? true;
|
|
392
|
+
this.depsReadonly = opts.depsReadonly ?? false;
|
|
393
|
+
|
|
394
|
+
if (!(this.timeoutS > 0)) throw new SandboxError("timeoutS must be a positive number of seconds");
|
|
395
|
+
if (!(this.maxOutputBytes > 0)) throw new SandboxError("maxOutputBytes must be positive");
|
|
396
|
+
|
|
397
|
+
this._mountArgs = [];
|
|
398
|
+
if (this.mounts) {
|
|
399
|
+
for (const [source, spec] of Object.entries(this.mounts)) {
|
|
400
|
+
let target, ro;
|
|
401
|
+
if (Array.isArray(spec)) {
|
|
402
|
+
const [t, mode] = spec;
|
|
403
|
+
if (mode !== "ro" && mode !== "rw")
|
|
404
|
+
throw new MountRefused(`mount mode must be 'ro' or 'rw', got ${JSON.stringify(mode)}`);
|
|
405
|
+
target = t;
|
|
406
|
+
ro = mode === "ro";
|
|
407
|
+
} else {
|
|
408
|
+
target = spec;
|
|
409
|
+
ro = false;
|
|
410
|
+
}
|
|
411
|
+
const [real, tgt] = validateMount(source, target);
|
|
412
|
+
this._mountArgs.push("-v", ro ? `${real}:${tgt}:ro` : `${real}:${tgt}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
416
|
+
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
417
|
+
if (this._egressAllow.length && this.network)
|
|
418
|
+
throw new SandboxError(
|
|
419
|
+
"egressAllow and network:true are mutually exclusive: egressAllow gives a restricted domain " +
|
|
420
|
+
"allowlist for runCode, network:true gives the full host network",
|
|
421
|
+
);
|
|
422
|
+
this._kern = findKern();
|
|
423
|
+
this._ws = "";
|
|
424
|
+
this._ownWs = false;
|
|
425
|
+
this._entered = false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// -- lifecycle -----------------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/** Open the session: create/validate the workspace and run `setup` (if any). Must be called before
|
|
431
|
+
* runCode/run/writeFile. Prefer withSandbox() which opens and closes for you. */
|
|
432
|
+
async open() {
|
|
433
|
+
if (this._entered) return this;
|
|
434
|
+
if (this.workspace === null) {
|
|
435
|
+
this._ws = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "kern-ws-")));
|
|
436
|
+
this._ownWs = true;
|
|
437
|
+
} else {
|
|
438
|
+
validateMount(this.workspace, WORKSPACE);
|
|
439
|
+
fs.mkdirSync(this.workspace, { recursive: true });
|
|
440
|
+
this._ws = fs.realpathSync(this.workspace);
|
|
441
|
+
this._ownWs = false;
|
|
442
|
+
}
|
|
443
|
+
this._entered = true;
|
|
444
|
+
if (this.setup) await this._runSetup(this.setup);
|
|
445
|
+
return this;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Close the session: delete the workspace iff we created it. Idempotent. */
|
|
449
|
+
async close() {
|
|
450
|
+
if (this._ownWs && this._ws) {
|
|
451
|
+
try {
|
|
452
|
+
fs.rmSync(this._ws, { recursive: true, force: true });
|
|
453
|
+
} catch {
|
|
454
|
+
/* best-effort */
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
this._entered = false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
_requireEntered() {
|
|
461
|
+
if (!this._entered)
|
|
462
|
+
throw new SandboxError("open the Sandbox first: `await sandbox.open()` (or use withSandbox()).");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// -- the box invocation --------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
_baseArgv(name, { network, timeoutS, isSetup = false }) {
|
|
468
|
+
const argv = [
|
|
469
|
+
this._kern, "box", name, "--image", this.image, "--ro",
|
|
470
|
+
"-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
|
|
471
|
+
];
|
|
472
|
+
if (this.depsReadonly && !isSetup) {
|
|
473
|
+
const deps = path.join(this._ws, DEPS_DIR);
|
|
474
|
+
try {
|
|
475
|
+
if (fs.statSync(deps).isDirectory()) argv.push("-v", `${deps}:${WORKSPACE}/${DEPS_DIR}:ro`);
|
|
476
|
+
} catch {
|
|
477
|
+
/* no deps yet */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
|
|
481
|
+
argv.push("--timeout", String(timeoutS + 5));
|
|
482
|
+
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
483
|
+
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
|
484
|
+
if (this.pids !== null) argv.push("--pids-limit", String(this.pids));
|
|
485
|
+
// Network mode: egressAllow (a domain allowlist via an isolated netns + kern's filtering proxy)
|
|
486
|
+
// governs the untrusted runCode/run boxes; the setup box keeps the full network it needs to install
|
|
487
|
+
// deps. egressAllow and network are mutually exclusive (checked at construction).
|
|
488
|
+
if (this._egressAllow.length && !isSetup) argv.push("--egress-allow", this._egressAllow.join(","));
|
|
489
|
+
else if (network) argv.push("--net");
|
|
490
|
+
// Resource profiles (vcpu:/vgpio:/vdisk:NAME): positional tokens `kern box` resolves against the
|
|
491
|
+
// user's kern.toml. Validated at construction, so nothing here can be a smuggled flag.
|
|
492
|
+
argv.push(...this._profileArgs);
|
|
493
|
+
argv.push(...this._mountArgs);
|
|
494
|
+
|
|
495
|
+
const mergedEnv = { ...(this.env || {}) };
|
|
496
|
+
if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
|
|
497
|
+
// Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
|
|
498
|
+
// `ps` to any local user for the box's lifetime; a credential in env= would leak).
|
|
499
|
+
if (Object.keys(mergedEnv).length > 0) {
|
|
500
|
+
const envPath = path.join(this._ws, ENV_FILE);
|
|
501
|
+
const lines = [];
|
|
502
|
+
for (const [k, v] of Object.entries(mergedEnv)) {
|
|
503
|
+
const val = String(v);
|
|
504
|
+
if (/[\n\0]/.test(k) || /[\n\0]/.test(val))
|
|
505
|
+
throw new SandboxError(`env var ${JSON.stringify(k)} must not contain a newline or NUL`);
|
|
506
|
+
lines.push(`${k}=${val}\n`);
|
|
507
|
+
}
|
|
508
|
+
// SECURITY: the box has rw access to /workspace and could plant `.kern-env` as a symlink to a host
|
|
509
|
+
// file (e.g. ~/.ssh/authorized_keys); a follow-through open would O_TRUNC-clobber it. Unlink any
|
|
510
|
+
// existing entry (removing a planted symlink) and create fresh with O_EXCL|O_NOFOLLOW so we never
|
|
511
|
+
// write through a symlink. Fails closed if a concurrent box re-plants it between the two calls.
|
|
512
|
+
try {
|
|
513
|
+
fs.unlinkSync(envPath);
|
|
514
|
+
} catch {
|
|
515
|
+
/* ENOENT is fine */
|
|
516
|
+
}
|
|
517
|
+
const fd = fs.openSync(
|
|
518
|
+
envPath,
|
|
519
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
|
520
|
+
0o600,
|
|
521
|
+
);
|
|
522
|
+
try {
|
|
523
|
+
fs.writeSync(fd, lines.join(""));
|
|
524
|
+
} finally {
|
|
525
|
+
fs.closeSync(fd);
|
|
526
|
+
}
|
|
527
|
+
argv.push("--env-file", envPath);
|
|
528
|
+
}
|
|
529
|
+
return argv;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
_spawn(command, { network, timeoutS, isSetup = false }) {
|
|
533
|
+
for (const part of command)
|
|
534
|
+
if (typeof part !== "string" || part.includes("\0"))
|
|
535
|
+
throw new SandboxError("command/code must be strings with no NUL byte");
|
|
536
|
+
const before = this._snapshot();
|
|
537
|
+
const name = uniqueName();
|
|
538
|
+
const argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
|
|
539
|
+
const childEnv = { ...process.env };
|
|
540
|
+
if (!this.enforceLimits) childEnv.KERN_NO_SCOPE = "1";
|
|
541
|
+
|
|
542
|
+
const started = process.hrtime.bigint();
|
|
543
|
+
return new Promise((resolve, reject) => {
|
|
544
|
+
let child;
|
|
545
|
+
try {
|
|
546
|
+
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
547
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
548
|
+
env: childEnv,
|
|
549
|
+
detached: true,
|
|
550
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
551
|
+
});
|
|
552
|
+
} catch (e) {
|
|
553
|
+
return reject(new SandboxError(`could not spawn the box: ${e.message}`));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const out = cappedCollector(child.stdout, this.maxOutputBytes, this.onStdout);
|
|
557
|
+
const err = cappedCollector(child.stderr, this.maxOutputBytes, this.onStderr);
|
|
558
|
+
let timedOut = false;
|
|
559
|
+
let settled = false;
|
|
560
|
+
|
|
561
|
+
const timer = setTimeout(() => {
|
|
562
|
+
timedOut = true;
|
|
563
|
+
this._teardown(child, name, childEnv);
|
|
564
|
+
}, timeoutS * 1000);
|
|
565
|
+
|
|
566
|
+
// Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it; never
|
|
567
|
+
// hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
|
|
568
|
+
let hardTimer = null;
|
|
569
|
+
const finish = (code, signal) => {
|
|
570
|
+
if (settled) return;
|
|
571
|
+
settled = true;
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
if (hardTimer) clearTimeout(hardTimer);
|
|
574
|
+
const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
|
|
575
|
+
const stdout = out.buffer().toString("utf8");
|
|
576
|
+
const stderr = err.buffer().toString("utf8");
|
|
577
|
+
const rc = toRc(code, signal);
|
|
578
|
+
const fault = this._classify(rc, signal, stderr, timedOut);
|
|
579
|
+
const files = this._diff(before);
|
|
580
|
+
resolve(
|
|
581
|
+
new ExecutionResult({
|
|
582
|
+
stdout, stderr, exitCode: rc, durationMs: wallMs, fault, files,
|
|
583
|
+
truncated: out.truncated || err.truncated,
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
child.on("error", (e) => {
|
|
589
|
+
if (e && e.code === "ENOENT")
|
|
590
|
+
return reject(new SandboxError(`could not execute kern (${argv[0]}): not found`));
|
|
591
|
+
return reject(new SandboxError(`could not execute kern: ${e.message}`));
|
|
592
|
+
});
|
|
593
|
+
child.on("close", (code, signal) => {
|
|
594
|
+
finish(code, signal);
|
|
595
|
+
});
|
|
596
|
+
// arm the hard net only once we've decided to kill (teardown sets timedOut)
|
|
597
|
+
const armHardNet = () => {
|
|
598
|
+
if (hardTimer) return;
|
|
599
|
+
hardTimer = setTimeout(() => finish(EXIT_SIGKILL, "SIGKILL"), 10000);
|
|
600
|
+
};
|
|
601
|
+
// re-check shortly after the deadline in case teardown fired
|
|
602
|
+
const watch = setInterval(() => {
|
|
603
|
+
if (settled) {
|
|
604
|
+
clearInterval(watch);
|
|
605
|
+
} else if (timedOut) {
|
|
606
|
+
clearInterval(watch);
|
|
607
|
+
armHardNet();
|
|
608
|
+
}
|
|
609
|
+
}, 250);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_teardown(child, name, childEnv) {
|
|
614
|
+
// Best-effort tear down a timed-out box. A CPU-bound box in its own PID namespace survives a plain
|
|
615
|
+
// SIGKILL of kern's parent: (1) `kern stop` (cgroup-kill); (2) SIGKILL the whole process group;
|
|
616
|
+
// (3) SIGKILL the child. kern's own --timeout backstop guarantees the box is gone shortly.
|
|
617
|
+
try {
|
|
618
|
+
spawnSync(this._kern, ["stop", name], { env: childEnv, timeout: 5000, stdio: "ignore" });
|
|
619
|
+
} catch {
|
|
620
|
+
/* ignore */
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL"); // process group (detached)
|
|
624
|
+
} catch {
|
|
625
|
+
/* ignore */
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
child.kill("SIGKILL");
|
|
629
|
+
} catch {
|
|
630
|
+
/* ignore */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
_classify(rc, signal, stderr, timedOut) {
|
|
635
|
+
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
636
|
+
// heuristic, because stderr is a channel the workload controls.
|
|
637
|
+
if (timedOut)
|
|
638
|
+
return sandboxFault("timeout", `exceeded the ${this.timeoutS}s time limit (killed by the binding)`);
|
|
639
|
+
if (rc === EXIT_SIGSYS || signal === "SIGSYS")
|
|
640
|
+
return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
|
|
641
|
+
if (rc === EXIT_SIGKILL || signal === "SIGKILL")
|
|
642
|
+
return sandboxFault("killed", "the box was killed (SIGKILL) - likely out of memory (exit 137)");
|
|
643
|
+
if (rc === EXIT_SIGTERM || signal === "SIGTERM")
|
|
644
|
+
return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
|
|
645
|
+
if (rc !== 0 && looksLikeStartupFailure(stderr))
|
|
646
|
+
return sandboxFault("startup_failed", stderr.trim().slice(0, 500));
|
|
647
|
+
// Any other non-zero exit (incl. 139 SIGSEGV) is the USER's code failing - a normal Result.
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// -- workspace file I/O (host-direct; single-uid -> box files are host-owned) ---------------------
|
|
652
|
+
|
|
653
|
+
_wsPath(rel) {
|
|
654
|
+
// Lexical containment: normalize `..`/`.`, require it stays under the workspace base. Symlinks in
|
|
655
|
+
// the final component are neutralized by O_NOFOLLOW on the actual open below.
|
|
656
|
+
const base = this._ws;
|
|
657
|
+
const full = path.normalize(path.join(base, rel));
|
|
658
|
+
if (full !== base && !full.startsWith(base + path.sep))
|
|
659
|
+
throw new SandboxError(`path escapes the workspace: ${JSON.stringify(rel)}`);
|
|
660
|
+
return full;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Create the parent directories of `full` under the workspace WITHOUT following a symlink in any
|
|
664
|
+
* intermediate component. `mkdir -p` follows symlinks, so a box that plants `a -> /etc` could steer a
|
|
665
|
+
* `writeFile("a/b.txt")` outside the workspace even though the final component is O_NOFOLLOW. Descend
|
|
666
|
+
* one level at a time from the (canonical) base: reject a symlink, create a missing dir non-recursively. */
|
|
667
|
+
_ensureParentDirs(full) {
|
|
668
|
+
const base = this._ws;
|
|
669
|
+
const relDir = path.relative(base, path.dirname(full));
|
|
670
|
+
if (relDir === "" || relDir === ".") return; // parent is the workspace root itself
|
|
671
|
+
let cur = base;
|
|
672
|
+
for (const part of relDir.split(path.sep)) {
|
|
673
|
+
if (!part || part === ".") continue;
|
|
674
|
+
const next = path.join(cur, part);
|
|
675
|
+
let st = null;
|
|
676
|
+
try {
|
|
677
|
+
st = fs.lstatSync(next);
|
|
678
|
+
} catch {
|
|
679
|
+
st = null;
|
|
680
|
+
}
|
|
681
|
+
if (st === null) {
|
|
682
|
+
fs.mkdirSync(next); // non-recursive: each level is a fresh real dir we just created
|
|
683
|
+
} else if (st.isSymbolicLink()) {
|
|
684
|
+
throw new SandboxError(`path escapes the workspace via a symlinked directory: ${JSON.stringify(part)}`);
|
|
685
|
+
} else if (!st.isDirectory()) {
|
|
686
|
+
throw new SandboxError(`workspace path component is not a directory: ${JSON.stringify(part)}`);
|
|
687
|
+
}
|
|
688
|
+
cur = next;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Write `data` (Buffer|string) to `path` (workspace-relative) - host-direct, so the box sees it next
|
|
693
|
+
* run. The final component is opened O_NOFOLLOW: a symlink the box planted can't redirect the write. */
|
|
694
|
+
async writeFile(rel, data) {
|
|
695
|
+
this._requireEntered();
|
|
696
|
+
const full = this._wsPath(rel);
|
|
697
|
+
this._ensureParentDirs(full); // symlink-safe descent, NOT mkdir -p (which follows a planted symlink)
|
|
698
|
+
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
699
|
+
let fd;
|
|
700
|
+
try {
|
|
701
|
+
fd = fs.openSync(
|
|
702
|
+
full,
|
|
703
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
|
|
704
|
+
0o644,
|
|
705
|
+
);
|
|
706
|
+
} catch (e) {
|
|
707
|
+
throw new SandboxError(`cannot write ${JSON.stringify(rel)}: ${e.message}`);
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
fs.writeSync(fd, payload);
|
|
711
|
+
} finally {
|
|
712
|
+
fs.closeSync(fd);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Read `path` (workspace-relative) from the workspace - host-direct. Final component O_NOFOLLOW. */
|
|
717
|
+
async readFile(rel) {
|
|
718
|
+
this._requireEntered();
|
|
719
|
+
const full = this._wsPath(rel);
|
|
720
|
+
let fd;
|
|
721
|
+
try {
|
|
722
|
+
fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
723
|
+
} catch (e) {
|
|
724
|
+
throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
return fs.readFileSync(fd);
|
|
728
|
+
} finally {
|
|
729
|
+
fs.closeSync(fd);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** List regular files under the workspace (excluding the .deps install dir and our env file). */
|
|
734
|
+
async listFiles(subdir = "") {
|
|
735
|
+
this._requireEntered();
|
|
736
|
+
const root = subdir ? this._wsPath(subdir) : this._ws;
|
|
737
|
+
const walked = this._walk(root);
|
|
738
|
+
return Object.entries(walked).map(([p, [, size]]) => ({ path: p, size, change: "created" }));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// -- workspace snapshot (a cheap FILESYSTEM checkpoint; NOT a memory snapshot) --------------------
|
|
742
|
+
|
|
743
|
+
/** Write a gzip tar of the whole workspace to `dest` on the host, a portable filesystem checkpoint.
|
|
744
|
+
* Pair with restore() (or seed a new Sandbox({ workspace })) to resume the FILE state later or
|
|
745
|
+
* elsewhere. NOT a memory snapshot: processes are ephemeral, only on-disk state is captured. */
|
|
746
|
+
// The Node snapshot/restore path uses a HAND-ROLLED ustar parser. While it is new, it is opt-in: set
|
|
747
|
+
// KERN_SANDBOX_SNAPSHOT=1 to enable it. Fails CLOSED (refuses, never silently degrades). The Python
|
|
748
|
+
// binding uses the stdlib `tarfile` and has no such gate. Remove this once the parser is battle-tested.
|
|
749
|
+
_requireSnapshotOptIn() {
|
|
750
|
+
if (process.env.KERN_SANDBOX_SNAPSHOT !== "1")
|
|
751
|
+
throw new SandboxError(
|
|
752
|
+
"snapshot/restore is opt-in in the Node binding while its archive parser is new: " +
|
|
753
|
+
"set KERN_SANDBOX_SNAPSHOT=1 to enable it (the Python binding uses stdlib tarfile and is always on)",
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
snapshot(dest) {
|
|
758
|
+
this._requireEntered();
|
|
759
|
+
this._requireSnapshotOptIn();
|
|
760
|
+
fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Extract a snapshot (from snapshot()) into the workspace, SAFELY. Every member is vetted first:
|
|
764
|
+
* absolute paths, `..` escapes and non-file/dir members (symlinks, devices, hardlinks) are refused,
|
|
765
|
+
* and each path must resolve under the workspace; the final component is opened O_NOFOLLOW. Colliding
|
|
766
|
+
* files are overwritten. */
|
|
767
|
+
restore(src) {
|
|
768
|
+
this._requireEntered();
|
|
769
|
+
this._requireSnapshotOptIn();
|
|
770
|
+
const base = fs.realpathSync(this._ws);
|
|
771
|
+
const members = tarParse(fs.readFileSync(src));
|
|
772
|
+
for (const m of members) {
|
|
773
|
+
if (m.name === "") continue;
|
|
774
|
+
if (m.name.startsWith("/") || m.name.split("/").includes(".."))
|
|
775
|
+
throw new SandboxError(`unsafe path in snapshot: ${JSON.stringify(m.name)}`);
|
|
776
|
+
if (m.type === "other")
|
|
777
|
+
throw new SandboxError(`unsafe member type in snapshot (only files/dirs): ${JSON.stringify(m.name)}`);
|
|
778
|
+
const resolved = path.resolve(base, m.name);
|
|
779
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep))
|
|
780
|
+
throw new SandboxError(`snapshot member escapes the workspace: ${JSON.stringify(m.name)}`);
|
|
781
|
+
}
|
|
782
|
+
for (const m of members) {
|
|
783
|
+
if (m.name === "") continue;
|
|
784
|
+
const dest = path.join(base, m.name);
|
|
785
|
+
// _ensureParentDirs descends one level at a time and REFUSES a symlinked component, so a symlink
|
|
786
|
+
// the box planted in the workspace (e.g. `evil -> ~/.ssh`) can't steer a member outside it. A
|
|
787
|
+
// plain mkdir -p would follow that symlink (the lexical pre-vet above does not resolve it).
|
|
788
|
+
this._ensureParentDirs(dest);
|
|
789
|
+
if (m.type === "dir") {
|
|
790
|
+
let st = null;
|
|
791
|
+
try {
|
|
792
|
+
st = fs.lstatSync(dest);
|
|
793
|
+
} catch {
|
|
794
|
+
st = null;
|
|
795
|
+
}
|
|
796
|
+
if (st === null) {
|
|
797
|
+
// mkdirSync is not O_NOFOLLOW: a box could swap `dest` for a symlink between _ensureParentDirs
|
|
798
|
+
// and here (mkdir-through-symlink -> an empty dir created OUTSIDE the workspace). Node has no
|
|
799
|
+
// mkdirat, so close the race by re-lstat'ing after: a symlink swapped in is caught. No member
|
|
800
|
+
// content is ever written through it (file writes use O_NOFOLLOW leaves).
|
|
801
|
+
try {
|
|
802
|
+
fs.mkdirSync(dest);
|
|
803
|
+
} catch (e) {
|
|
804
|
+
if (e.code !== "EEXIST") throw e;
|
|
805
|
+
}
|
|
806
|
+
const post = fs.lstatSync(dest);
|
|
807
|
+
if (post.isSymbolicLink() || !post.isDirectory())
|
|
808
|
+
throw new SandboxError(`snapshot dir member is not a real directory: ${JSON.stringify(m.name)}`);
|
|
809
|
+
} else if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
810
|
+
throw new SandboxError(`snapshot dir member collides with a non-directory: ${JSON.stringify(m.name)}`);
|
|
811
|
+
}
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// O_NOFOLLOW: a symlink already planted at this leaf can't redirect the write outside the workspace.
|
|
815
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW;
|
|
816
|
+
const fd = fs.openSync(dest, flags, 0o644);
|
|
817
|
+
try {
|
|
818
|
+
fs.writeSync(fd, m.content);
|
|
819
|
+
} finally {
|
|
820
|
+
fs.closeSync(fd);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// -- setup (the only network window) -------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
async _runSetup(cmd) {
|
|
828
|
+
// The network is ON only here, in a SEPARATE setup box that dies at the end. `pip install X` is
|
|
829
|
+
// routed to <workspace>/.deps; every runCode box is network-off.
|
|
830
|
+
const install = `pip install --target ${WORKSPACE}/${DEPS_DIR} --no-cache-dir --disable-pip-version-check`;
|
|
831
|
+
let shellCmd = cmd;
|
|
832
|
+
if (cmd.trim().startsWith("pip install "))
|
|
833
|
+
shellCmd = install + " " + cmd.trim().slice("pip install ".length);
|
|
834
|
+
const r = await this._spawn(["sh", "-c", shellCmd], {
|
|
835
|
+
network: true,
|
|
836
|
+
timeoutS: Math.max(this.timeoutS, 120),
|
|
837
|
+
isSetup: true,
|
|
838
|
+
});
|
|
839
|
+
if (!r.success)
|
|
840
|
+
throw new SandboxError(`setup failed (exit ${r.exitCode}): ${(r.stderr || r.stdout).trim().slice(0, 400)}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// -- files diff (created/modified; excludes .deps and our env file) ------------------------------
|
|
844
|
+
|
|
845
|
+
_snapshot() {
|
|
846
|
+
return this._walk(this._ws);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
_walk(root) {
|
|
850
|
+
const base = this._ws;
|
|
851
|
+
const out = {};
|
|
852
|
+
const stack = [root];
|
|
853
|
+
while (stack.length) {
|
|
854
|
+
const dir = stack.pop();
|
|
855
|
+
let entries;
|
|
856
|
+
try {
|
|
857
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
858
|
+
} catch {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
for (const ent of entries) {
|
|
862
|
+
if (ent.isDirectory()) {
|
|
863
|
+
if (ent.name === DEPS_DIR) continue; // exclude deps from the diff
|
|
864
|
+
stack.push(path.join(dir, ent.name));
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const fp = path.join(dir, ent.name);
|
|
868
|
+
let st;
|
|
869
|
+
try {
|
|
870
|
+
st = fs.lstatSync(fp);
|
|
871
|
+
} catch {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!st.isFile()) continue; // excludes symlinks and non-regular files
|
|
875
|
+
const rel = path.relative(base, fp);
|
|
876
|
+
if (rel === ENV_FILE) continue; // our private host-side env file, not a user artifact
|
|
877
|
+
out[rel] = [Math.round(st.mtimeMs * 1e6), st.size];
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
_diff(before) {
|
|
884
|
+
const after = this._snapshot();
|
|
885
|
+
const files = [];
|
|
886
|
+
for (const [rel, meta] of Object.entries(after)) {
|
|
887
|
+
if (!(rel in before)) files.push({ path: rel, size: meta[1], change: "created" });
|
|
888
|
+
else if (before[rel][0] !== meta[0] || before[rel][1] !== meta[1])
|
|
889
|
+
files.push({ path: rel, size: meta[1], change: "modified" });
|
|
890
|
+
}
|
|
891
|
+
return files;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// -- the two ways to run code --------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
/** Run a snippet of `code` on the workspace in a fresh, network-off box. File state persists to the
|
|
897
|
+
* next call; in-memory state does NOT. `language` is "python" (default), "bash", or "node". Large
|
|
898
|
+
* code is written to a workspace file and run by path (no argv-size limit). */
|
|
899
|
+
async runCode(code, { language = "python" } = {}) {
|
|
900
|
+
this._requireEntered();
|
|
901
|
+
// Each runner: [binary, inline-eval-flag, file-extension]. Note node evaluates with `-e`, NOT `-c`
|
|
902
|
+
// (which is node's syntax-CHECK flag and would run nothing); python/sh use `-c`.
|
|
903
|
+
const runners = {
|
|
904
|
+
python: ["python3", "-c", "py"],
|
|
905
|
+
bash: ["sh", "-c", "sh"],
|
|
906
|
+
node: ["node", "-e", "js"],
|
|
907
|
+
};
|
|
908
|
+
const spec = runners[language];
|
|
909
|
+
if (!spec)
|
|
910
|
+
throw new SandboxError(`unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'node')`);
|
|
911
|
+
const [runner, evalFlag, ext] = spec;
|
|
912
|
+
let command;
|
|
913
|
+
if (Buffer.byteLength(code, "utf8") > INLINE_CODE_MAX) {
|
|
914
|
+
const cell = `.cell-${crypto.randomBytes(4).toString("hex")}.${ext}`;
|
|
915
|
+
await this.writeFile(cell, code);
|
|
916
|
+
command = [runner, `${WORKSPACE}/${cell}`];
|
|
917
|
+
} else {
|
|
918
|
+
command = [runner, evalFlag, code];
|
|
919
|
+
}
|
|
920
|
+
return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Run an arbitrary `command` (an argv ARRAY, never a shell string) in a fresh box. */
|
|
924
|
+
async run(command) {
|
|
925
|
+
this._requireEntered();
|
|
926
|
+
if (typeof command === "string")
|
|
927
|
+
throw new SandboxError('run() takes an argv ARRAY, not a string. Use run(["sh","-c","..."]).');
|
|
928
|
+
if (!Array.isArray(command) || command.length === 0)
|
|
929
|
+
throw new SandboxError("run() needs a non-empty command array");
|
|
930
|
+
return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Open a Sandbox, run `fn(sandbox)`, and close it (deleting a temp workspace) even if `fn` throws.
|
|
935
|
+
* The idiomatic session helper - the equivalent of Python's `with Sandbox() as s:`. */
|
|
936
|
+
async function withSandbox(opts, fn) {
|
|
937
|
+
if (typeof opts === "function") {
|
|
938
|
+
fn = opts;
|
|
939
|
+
opts = {};
|
|
940
|
+
}
|
|
941
|
+
const sbx = new Sandbox(opts);
|
|
942
|
+
await sbx.open();
|
|
943
|
+
try {
|
|
944
|
+
return await fn(sbx);
|
|
945
|
+
} finally {
|
|
946
|
+
await sbx.close();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** One-shot convenience: run `code` in a throwaway session (workspace created and deleted). Equivalent
|
|
951
|
+
* to `withSandbox(opts, s => s.runCode(code, {language}))`. For multi-step work, use withSandbox(). */
|
|
952
|
+
async function runCode(code, opts = {}) {
|
|
953
|
+
const { language = "python", ...rest } = opts;
|
|
954
|
+
return withSandbox(rest, (s) => s.runCode(code, { language }));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
module.exports = {
|
|
958
|
+
Sandbox,
|
|
959
|
+
withSandbox,
|
|
960
|
+
runCode,
|
|
961
|
+
ExecutionResult,
|
|
962
|
+
SandboxError,
|
|
963
|
+
MountRefused,
|
|
964
|
+
version: VERSION,
|
|
965
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kern-sandbox",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox. A thin, dependency-free wrapper around the kern binary.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sandbox",
|
|
7
|
+
"kern",
|
|
8
|
+
"agent",
|
|
9
|
+
"llm",
|
|
10
|
+
"code-execution",
|
|
11
|
+
"isolation",
|
|
12
|
+
"seccomp",
|
|
13
|
+
"container",
|
|
14
|
+
"e2b"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/getkern/kern/tree/main/bindings/node",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/getkern/kern.git",
|
|
20
|
+
"directory": "bindings/node"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/getkern/kern/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"main": "index.js",
|
|
27
|
+
"types": "index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"index.js",
|
|
30
|
+
"index.d.ts",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {},
|
|
40
|
+
"os": [
|
|
41
|
+
"linux"
|
|
42
|
+
]
|
|
43
|
+
}
|