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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # kern-sandbox (Node.js / TypeScript)
2
2
 
3
- **[kern](https://github.com/getkern/kern)** is a fast, rootless sandbox and virtual resource
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 **1.52 MB** binary, with no daemon.
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, about 1.52 MB, with no cloud, no account, no VM.
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.32";
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
- if (boxStarted && fault && fault.type === "startup_failed") {
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 | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
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
- fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
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 && fs.fstatSync(fd).size > maxBytes)
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.32",
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://github.com/getkern/kern/tree/main/bindings/node",
18
+ "homepage": "https://getkern.dev",
19
19
  "repository": {
20
20
  "type": "git",
21
21
  "url": "git+https://github.com/getkern/kern.git",