kern-sandbox 0.1.32 → 0.1.34
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 +19 -4
- package/index.d.ts +1 -1
- package/index.js +76 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# kern-sandbox (Node.js / TypeScript)
|
|
2
2
|
|
|
3
|
-
**[kern](https://
|
|
3
|
+
**[kern](https://getkern.dev)** is a fast, rootless sandbox and virtual resource
|
|
4
4
|
runtime for any workload, including untrusted and AI-generated code: a real, kernel-enforced box
|
|
5
|
-
that starts in **~3.5 ms** from an OCI image, out of one
|
|
5
|
+
that starts in **~3.5 ms** from an OCI image, out of one static binary, with no daemon.
|
|
6
6
|
**kern-sandbox**
|
|
7
7
|
is its Node / TypeScript binding: run untrusted or agent-generated code in a fresh, isolated box, from Node.
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ package is on PyPI: [`kern-sandbox`](https://pypi.org/project/kern-sandbox/).
|
|
|
11
11
|
|
|
12
12
|
It is a thin, dependency-free wrapper around the [`kern`](https://github.com/getkern/kern) binary:
|
|
13
13
|
a fresh, isolated box per call, network off by default, hard resource caps, and a timeout the binding
|
|
14
|
-
itself enforces. Kernel-enforced isolation (namespaces, cgroups v2, seccomp), local,
|
|
14
|
+
itself enforces. Kernel-enforced isolation (namespaces, cgroups v2, seccomp), local, with no cloud, no account, no VM.
|
|
15
15
|
|
|
16
16
|
```js
|
|
17
17
|
const kern = require("kern-sandbox");
|
|
@@ -113,6 +113,16 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
|
|
|
113
113
|
| `oom` | the box was SIGKILLed and a `memoryMb` cap was **in force**: a breached `memory.max` is the cgroup OOM-killer (`memory.oom.group=1` kills the whole box). kern reports whether the cap actually bound on an unforgeable per-box channel (2nd byte of `KERN_STARTED_FD`), so this is an *enforced-cap* OOM |
|
|
114
114
|
| `killed` | a SIGKILL **not** attributed to a cgroup OOM: no `memoryMb` cap was set, or kern reported the cap did not bind here (no cgroup delegation), so it is host pressure / an external kill. Older kern (no enforcement byte) falls back to `oom` when a cap was set |
|
|
115
115
|
|
|
116
|
+
| `exec_failed` | the box started but the command did not exist inside it. `runCode(code, {language:"node"})` on an image with no `node` is the ordinary way to reach it; the message names the binary AND the image, because the remedy is a different `language` or a different `image`. The `language` enum is a convenience, not a promise about the image: the default `python:3.12-slim` carries `python` and `bash`. A shell's own `command not found` inside your script stays an ordinary non-zero exit |
|
|
117
|
+
|
|
118
|
+
**An enforced `pids` cap produces no fault, and that is deliberate.** When `pids` binds, the refused
|
|
119
|
+
`fork` returns `EAGAIN`. Code that catches it exits 0, so the call reports `fault: null, success:
|
|
120
|
+
true` and a contained fork bomb reads as a successful run. `EAGAIN` is an ordinary errno a program is
|
|
121
|
+
allowed to handle, unlike a SIGKILL it cannot; labelling it a sandbox fault would misreport a process
|
|
122
|
+
that exited cleanly. Code that does **not** catch it dies naming "Resource temporarily unavailable".
|
|
123
|
+
The cap itself is enforced: on WSL2, `pids: 32` blocked at 29 forks while `pids: 256` let 120 through,
|
|
124
|
+
same code and same image.
|
|
125
|
+
|
|
116
126
|
A box that fails to **start** (kern exits 125: a mount refused at runtime, an unmappable `--user`, a
|
|
117
127
|
seccomp/AppArmor/cgroup setup error, or a pull/image error) is **thrown** as a `SandboxError`, not
|
|
118
128
|
returned as a fault, because the code never ran.
|
|
@@ -137,7 +147,12 @@ Every relaxing option says so in its name or docs:
|
|
|
137
147
|
- **mounts refused**: sensitive host sources (`/`, `/etc`, `/root`, `/proc`, `/sys`, `/dev`, the docker
|
|
138
148
|
socket, `$HOME`) and escaping targets are refused even when asked.
|
|
139
149
|
- **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes and open the final component
|
|
140
|
-
`O_NOFOLLOW`, so a symlink the box plants cannot redirect host I/O outside the workspace.
|
|
150
|
+
`O_NOFOLLOW`, so a symlink the box plants cannot redirect host I/O outside the workspace. They also
|
|
151
|
+
open `O_NONBLOCK` and refuse a descriptor that is not a REGULAR file. A symlink is not the only thing
|
|
152
|
+
a box can leave at a name: `mkfifo out.png` used to make `readFile("out.png")` wait for a writer that
|
|
153
|
+
never comes, with no timeout, so the box chose how long the host's call took. The flag alone would be
|
|
154
|
+
worse than the hang, because a non-blocking read of a writer-less FIFO returns zero bytes and the
|
|
155
|
+
call would report an EMPTY FILE. Both halves ship: it returns promptly, and it refuses.
|
|
141
156
|
|
|
142
157
|
### Options
|
|
143
158
|
|
package/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
|
|
3
3
|
|
|
4
4
|
/** What stopped the code at the SANDBOX level. Reported as data on a result, never thrown. */
|
|
5
|
-
export type SandboxFaultType = "timeout" | "oom" | "escape_blocked" | "killed" | "startup_failed";
|
|
5
|
+
export type SandboxFaultType = "timeout" | "oom" | "escape_blocked" | "killed" | "startup_failed" | "exec_failed";
|
|
6
6
|
|
|
7
7
|
export interface SandboxFault {
|
|
8
8
|
type: SandboxFaultType;
|
package/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const crypto = require("crypto");
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const { spawn, spawnSync } = require("child_process");
|
|
38
38
|
|
|
39
|
-
const VERSION = "0.1.
|
|
39
|
+
const VERSION = "0.1.34";
|
|
40
40
|
|
|
41
41
|
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
42
42
|
const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
|
|
@@ -624,6 +624,28 @@ function toRc(code, signal) {
|
|
|
624
624
|
|
|
625
625
|
/** True iff kern (the PARENT, before the box exists) failed to start the box. Anchored on kern's OWN
|
|
626
626
|
* diagnostic prefixes so the workload can't forge them by writing the marker to its own stderr. */
|
|
627
|
+
const EXEC_FAILED_RE = /^kern: cannot start '([^']+)' in box: ([^\n]*)/m;
|
|
628
|
+
|
|
629
|
+
/** The binary kern could not exec, or null.
|
|
630
|
+
*
|
|
631
|
+
* A THIRD state, and the reason this exists: kern signals "box started" on its unforgeable fd BEFORE
|
|
632
|
+
* it execs the workload, so an `execve` that fails with ENOENT leaves a box that demonstrably started
|
|
633
|
+
* and a command that never ran. The classifier gets that right (kern's own marker is on stderr, so it
|
|
634
|
+
* says `startup_failed`) and the caller then ERASES it, because "box started + a kern: marker" is its
|
|
635
|
+
* signal that a WORKLOAD forged the marker. For this case that inference is wrong: the workload never
|
|
636
|
+
* ran, so it cannot have written anything.
|
|
637
|
+
*
|
|
638
|
+
* Matched on kern's own wording rather than on exit 127 alone, because 127 is also what a shell
|
|
639
|
+
* returns for `command not found` inside a script the user wrote, which IS the user's failure.
|
|
640
|
+
*
|
|
641
|
+
* A workload CAN print this line and exit 127 to be labelled `exec_failed` instead of a plain
|
|
642
|
+
* failure. That is accepted: it downgrades nothing security-relevant, because timeout, OOM and
|
|
643
|
+
* blocked-escape are decided by EXIT CODE before any stderr is read. */
|
|
644
|
+
function execFailureBinary(stderr) {
|
|
645
|
+
const m = EXEC_FAILED_RE.exec(stderr || "");
|
|
646
|
+
return m ? { what: m[1], reason: (m[2] || "").trim() } : null;
|
|
647
|
+
}
|
|
648
|
+
|
|
627
649
|
function looksLikeStartupFailure(stderr) {
|
|
628
650
|
const markers = [
|
|
629
651
|
"kern:",
|
|
@@ -1101,7 +1123,28 @@ class Sandbox {
|
|
|
1101
1123
|
const stderr = err.buffer().toString("utf8");
|
|
1102
1124
|
const rc = toRc(code, signal);
|
|
1103
1125
|
let fault = this._classify(rc, signal, stderr, timedOut, timeoutS, capSignal);
|
|
1104
|
-
|
|
1126
|
+
const execFail = execFailureBinary(stderr);
|
|
1127
|
+
if (execFail !== null && rc !== 0) {
|
|
1128
|
+
// BEFORE the suppression below, which would erase it: the box started, so that branch
|
|
1129
|
+
// would read kern's own marker as a workload forgery.
|
|
1130
|
+
//
|
|
1131
|
+
// The REASON is carried through rather than assumed. The first version said "does not
|
|
1132
|
+
// exist in the box" for every case, and exit 126 (EACCES: the file is there and is not
|
|
1133
|
+
// executable) and a script whose interpreter line names a missing binary both got a
|
|
1134
|
+
// message blaming the image for a file that exists.
|
|
1135
|
+
const { what, reason } = execFail;
|
|
1136
|
+
let detail;
|
|
1137
|
+
if (reason.includes("No such file or directory")) {
|
|
1138
|
+
detail =
|
|
1139
|
+
`No such file or directory. The image '${this.image}' does not provide it, or its ` +
|
|
1140
|
+
`interpreter line names something the image lacks.`;
|
|
1141
|
+
} else if (reason.includes("Permission denied")) {
|
|
1142
|
+
detail = "Permission denied: it is present in the box but not executable there.";
|
|
1143
|
+
} else {
|
|
1144
|
+
detail = reason || "the box could not execute it";
|
|
1145
|
+
}
|
|
1146
|
+
fault = sandboxFault("exec_failed", `'${what}' could not be started in the box: ${detail}`);
|
|
1147
|
+
} else if (boxStarted && fault && fault.type === "startup_failed") {
|
|
1105
1148
|
// kern signalled the box STARTED, so a `startup_failed` here is only the stderr heuristic
|
|
1106
1149
|
// matching a marker the WORKLOAD wrote (code-based faults are decided first). The box
|
|
1107
1150
|
// demonstrably ran: this is the workload's own non-zero exit - reclassify to a normal result.
|
|
@@ -1264,15 +1307,29 @@ class Sandbox {
|
|
|
1264
1307
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
1265
1308
|
let fd;
|
|
1266
1309
|
try {
|
|
1310
|
+
// O_NONBLOCK for the same reason as readFile, and the write side is the WORSE of the two: opening
|
|
1311
|
+
// a FIFO for writing blocks until a reader appears, and with the flag it fails outright (ENXIO)
|
|
1312
|
+
// instead. Either way the call returns to the caller rather than parking there.
|
|
1267
1313
|
fd = fs.openSync(
|
|
1268
1314
|
full,
|
|
1269
|
-
fs.constants.O_WRONLY |
|
|
1315
|
+
fs.constants.O_WRONLY |
|
|
1316
|
+
fs.constants.O_CREAT |
|
|
1317
|
+
fs.constants.O_TRUNC |
|
|
1318
|
+
fs.constants.O_NOFOLLOW |
|
|
1319
|
+
fs.constants.O_NONBLOCK,
|
|
1270
1320
|
0o644,
|
|
1271
1321
|
);
|
|
1272
1322
|
} catch (e) {
|
|
1273
1323
|
throw new SandboxError(`cannot write ${JSON.stringify(rel)}: ${e.message}`);
|
|
1274
1324
|
}
|
|
1275
1325
|
try {
|
|
1326
|
+
// The file the box left at this name has to be a REGULAR file before we write into it: writing
|
|
1327
|
+
// into a device node or a socket the box planted is host I/O it chose the target of.
|
|
1328
|
+
if (!fs.fstatSync(fd).isFile())
|
|
1329
|
+
throw new SandboxError(
|
|
1330
|
+
`refusing to write ${JSON.stringify(rel)}: not a regular file (a FIFO, device or socket ` +
|
|
1331
|
+
`planted in the workspace can stall or redirect this write)`,
|
|
1332
|
+
);
|
|
1276
1333
|
fs.writeSync(fd, payload);
|
|
1277
1334
|
} finally {
|
|
1278
1335
|
fs.closeSync(fd);
|
|
@@ -1330,14 +1387,28 @@ class Sandbox {
|
|
|
1330
1387
|
this._verifyParentDirs(full); // fast reject + nice error before we open (host-leak guard)
|
|
1331
1388
|
let fd;
|
|
1332
1389
|
try {
|
|
1333
|
-
|
|
1390
|
+
// O_NONBLOCK: opening a FIFO returns a descriptor instead of WAITING FOR A WRITER. Measured
|
|
1391
|
+
// before this flag: a box that runs `mkfifo out.png` makes `readFile("out.png")` hang with no
|
|
1392
|
+
// timeout and no way to interrupt it, so the box decides how long the host's call takes. That is
|
|
1393
|
+
// a denial of service the workspace hands out for free, and O_NOFOLLOW does not touch it.
|
|
1394
|
+
fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
1334
1395
|
} catch (e) {
|
|
1335
1396
|
throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
|
|
1336
1397
|
}
|
|
1337
1398
|
try {
|
|
1338
1399
|
this._assertFdInWorkspace(fd, rel); // race-free backstop: a swapped-in parent symlink is caught here
|
|
1400
|
+
// AND THE FLAG ALONE WOULD BE WORSE THAN THE HANG. A non-blocking read of a writer-less FIFO
|
|
1401
|
+
// returns zero bytes, so `readFile` would answer `<Buffer >` and the caller would read an empty
|
|
1402
|
+
// file where the box had planted a pipe. Refuse anything that is not a REGULAR file: FIFO,
|
|
1403
|
+
// device, socket, directory. Judged on the OPEN DESCRIPTOR, not on a path that can be swapped.
|
|
1404
|
+
const st = fs.fstatSync(fd);
|
|
1405
|
+
if (!st.isFile())
|
|
1406
|
+
throw new SandboxError(
|
|
1407
|
+
`refusing to read ${JSON.stringify(rel)}: not a regular file (a FIFO, device or socket ` +
|
|
1408
|
+
`planted in the workspace can stall or fake this read)`,
|
|
1409
|
+
);
|
|
1339
1410
|
// maxBytes caps the read so a file a not-fully-trusted box wrote can't OOM the host.
|
|
1340
|
-
if (maxBytes !== null &&
|
|
1411
|
+
if (maxBytes !== null && st.size > maxBytes)
|
|
1341
1412
|
throw new SandboxError(`${JSON.stringify(rel)} exceeds maxBytes=${maxBytes}`);
|
|
1342
1413
|
return fs.readFileSync(fd);
|
|
1343
1414
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "kern is a fast, rootless sandbox and virtual resource runtime for any workload, including untrusted and AI-generated code; kern-sandbox is its Node/TypeScript binding. Run untrusted or agent-generated code (Python/JS/Bash) in a real, kernel-enforced box in single-digit milliseconds, with no cloud, no account and no VM.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"jupyter",
|
|
16
16
|
"e2b"
|
|
17
17
|
],
|
|
18
|
-
"homepage": "https://
|
|
18
|
+
"homepage": "https://getkern.dev",
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/getkern/kern.git",
|