kern-sandbox 0.1.17 → 0.1.20

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **[kern](https://github.com/getkern/kern)** 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.4 ms** from an OCI image, out of one **~1.8 MB** binary, with no daemon.
5
+ that starts in **3.4 ms** from an OCI image, out of one **1.58 MB** 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.8 MB, with no cloud, no account, no VM.
14
+ itself enforces. Kernel-enforced isolation (namespaces, cgroups v2, seccomp), local, about 1.58 MB, with no cloud, no account, no VM.
15
15
 
16
16
  ```js
17
17
  const kern = require("kern-sandbox");
@@ -98,7 +98,8 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
98
98
  |---|---|
99
99
  | `timeout` | the call exceeded `timeoutS`; the binding killed the box |
100
100
  | `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
101
- | `killed` | the box was SIGKILLed, most often the cgroup OOM-killer |
101
+ | `oom` | the box was SIGKILLed with a `memoryMb` cap in effect: a breached `memory.max` is the cgroup OOM-killer (`memory.oom.group=1` kills the whole box) |
102
+ | `killed` | the box was SIGKILLed with **no** memory cap set, so the cause is ambiguous (host pressure, an external kill) and is not attributed to OOM |
102
103
 
103
104
  A box that fails to **start** (kern exits 125: a mount refused at runtime, an unmappable `--user`, a
104
105
  seccomp/AppArmor/cgroup setup error, or a pull/image error) is **thrown** as a `SandboxError`, not
@@ -225,11 +226,12 @@ clear error otherwise). The Python binding uses the stdlib `tarfile` and has no
225
226
  ## Honest threat model
226
227
 
227
228
  kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code (CI, dev, edge, your
228
- agents' code). Its seccomp filter is a **denylist**: right for semi-trusted agent code, **not** a hard
229
- boundary against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker /
230
- Kata) or gVisor. A deny-by-default seccomp **allowlist** ships as opt-in today: pass
231
- `securityProfile: "untrusted"` (or the `KERN_SECCOMP=allowlist` env); making it the default is future
232
- work. See the project's [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
229
+ agents' code). Its default seccomp filter is a **deny-by-default allowlist** (moby's own default
230
+ filter minus kern's 35 escape syscalls): right for semi-trusted agent code, **not** a hard boundary
231
+ against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker / Kata) or
232
+ gVisor. The wider denylist is the opt-out (`KERN_SECCOMP=denylist`), and `securityProfile: "untrusted"`
233
+ bundles the allowlist with `--cap-drop ALL` + `--read-only`. See the project's
234
+ [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
233
235
 
234
236
  ## License
235
237
 
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" | "escape_blocked" | "killed" | "startup_failed";
5
+ export type SandboxFaultType = "timeout" | "oom" | "escape_blocked" | "killed" | "startup_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.17";
39
+ const VERSION = "0.1.19";
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
@@ -465,6 +465,11 @@ class ExecutionResult {
465
465
  }
466
466
  }
467
467
 
468
+ /** A sandbox event `{type, message}` for `result.fault`. NB: `startup_failed` is decided from an
469
+ * UNFORGEABLE kern signal (a byte on fd 3 / `KERN_STARTED_FD` a workload can neither write nor
470
+ * suppress). Against a kern too old to send it, the binding falls back to a stderr heuristic that can
471
+ * only OVER-report - a workload can make its own exit look like a start failure - never MISS a real
472
+ * one, so it fails in the safe direction. Pair this binding with the matching (or newer) kern release. */
468
473
  function sandboxFault(type, message) {
469
474
  return { type, message };
470
475
  }
@@ -621,8 +626,14 @@ function looksLikeStartupFailure(stderr) {
621
626
  "error: oci:",
622
627
  "error: image:",
623
628
  ];
629
+ // kern also writes BENIGN `kern:` diagnostics that are NOT a box-start failure: the
630
+ // `--security-profile` posture banner, and `warning:`/`note:` lines. They start with `kern:` too, so
631
+ // without this skip a workload that merely exits non-zero WHILE one is on stderr (e.g. code run under
632
+ // securityProfile: "untrusted" that hits a network error) would be mislabeled `startup_failed`.
633
+ const benign = ["kern: security-profile=", "kern: warning:", "kern: note:"];
624
634
  for (const line of stderr.split("\n")) {
625
635
  const s = line.replace(/^\s+/, "");
636
+ if (benign.some((b) => s.startsWith(b))) continue;
626
637
  if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
627
638
  }
628
639
  return false;
@@ -1021,22 +1032,39 @@ class Sandbox {
1021
1032
  const argv = [...this._baseArgv(name, { network, timeoutS, isSetup }), "--", ...command];
1022
1033
  const childEnv = { ...process.env };
1023
1034
  if (!this.enforceLimits) childEnv.KERN_NO_SCOPE = "1";
1035
+ // Unforgeable "box started" channel: kern writes one byte to fd 3 iff its sandbox setup SUCCEEDED
1036
+ // and the command ran. The workload never holds fd 3, so it can neither forge nor suppress it -
1037
+ // unlike kern's stderr, which it can. A new kern makes this the authority for `startup_failed`; an
1038
+ // OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
1039
+ // compatible).
1040
+ childEnv.KERN_STARTED_FD = "3";
1024
1041
 
1025
1042
  const started = process.hrtime.bigint();
1026
1043
  return new Promise((resolve, reject) => {
1027
1044
  let child;
1045
+ let boxStarted = false;
1028
1046
  try {
1029
1047
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
1048
+ // The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
1030
1049
  child = spawn(argv[0], argv.slice(1), {
1031
1050
  env: childEnv,
1032
1051
  detached: true,
1033
- stdio: ["ignore", "pipe", "pipe"],
1052
+ stdio: ["ignore", "pipe", "pipe", "pipe"],
1034
1053
  });
1035
1054
  } catch (e) {
1036
1055
  this._removeEnvFile(name);
1037
1056
  return reject(new SandboxError(`could not spawn the box: ${e.message}`));
1038
1057
  }
1039
1058
 
1059
+ const startedCh = child.stdio[3];
1060
+ if (startedCh) {
1061
+ // One byte (0x01) = the box started; stream end with no byte = never started / old kern.
1062
+ startedCh.on("data", (b) => {
1063
+ if (b.length && b[0] === 1) boxStarted = true;
1064
+ });
1065
+ startedCh.on("error", () => {});
1066
+ }
1067
+
1040
1068
  const out = cappedCollector(child.stdout, this.maxOutputBytes, cbOut);
1041
1069
  const err = cappedCollector(child.stderr, this.maxOutputBytes, cbErr);
1042
1070
  let timedOut = false;
@@ -1059,7 +1087,13 @@ class Sandbox {
1059
1087
  const stdout = out.buffer().toString("utf8");
1060
1088
  const stderr = err.buffer().toString("utf8");
1061
1089
  const rc = toRc(code, signal);
1062
- const fault = this._classify(rc, signal, stderr, timedOut, timeoutS);
1090
+ let fault = this._classify(rc, signal, stderr, timedOut, timeoutS);
1091
+ if (boxStarted && fault && fault.type === "startup_failed") {
1092
+ // kern signalled the box STARTED, so a `startup_failed` here is only the stderr heuristic
1093
+ // matching a marker the WORKLOAD wrote (code-based faults are decided first). The box
1094
+ // demonstrably ran: this is the workload's own non-zero exit - reclassify to a normal result.
1095
+ fault = null;
1096
+ }
1063
1097
  // A box that FAILED TO START ran no user code, so REJECT rather than resolve a hollow
1064
1098
  // ExecutionResult (empty stdout). Gated on `rc === 125` (kern's box-not-started code) AND the
1065
1099
  // startup_failed classification (which requires kern's own stderr marker): the confident pair
@@ -1137,8 +1171,15 @@ class Sandbox {
1137
1171
  );
1138
1172
  if (rc === EXIT_SIGSYS || signal === "SIGSYS")
1139
1173
  return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
1140
- if (rc === EXIT_SIGKILL || signal === "SIGKILL")
1141
- return sandboxFault("killed", "the box was killed (SIGKILL) - likely out of memory (exit 137)");
1174
+ if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
1175
+ // A memory-capped box SIGKILLed is the cgroup OOM-killer - what a breached memory.max does (kern
1176
+ // sets memory.oom.group=1, so the whole box dies at once). Claim `oom` when a --memory cap was in
1177
+ // effect: that is a fact WE set on the argv, not the workload's stderr, so it costs no security
1178
+ // discipline. Uncapped, the cause is ambiguous (host pressure, an external kill) - keep `killed`.
1179
+ if (this.memoryMb !== null)
1180
+ return sandboxFault("oom", "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)");
1181
+ return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
1182
+ }
1142
1183
  if (rc === EXIT_SIGTERM || signal === "SIGTERM")
1143
1184
  return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
1144
1185
  // Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
@@ -1720,8 +1761,8 @@ class Kernel {
1720
1761
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
1721
1762
  if (reply === null) {
1722
1763
  const err = this._stderr.toString("utf8");
1723
- const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
1724
- return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
1764
+ const [kind, dflt] = this._kernelDeathFault(err);
1765
+ return this._teardownResult(kind, err.trim() || dflt, started);
1725
1766
  }
1726
1767
  return this._resultFromReply(reply, started);
1727
1768
  }
@@ -1767,6 +1808,19 @@ class Kernel {
1767
1808
  });
1768
1809
  }
1769
1810
 
1811
+ /** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. A kern setup marker on
1812
+ * stderr means it never came up (startup_failed). Otherwise, with a memoryMb cap in force, the cgroup
1813
+ * OOM-killer is the dominant cause of a kernel that disappears while running a cell - the SAME
1814
+ * attribution (from the same non-workload signal, the --memory flag WE set) as the one-shot _classify
1815
+ * SIGKILL path. A kernel death has no per-cell exit code to route through _classify, so the OOM
1816
+ * attribution lives here too. Uncapped, the cause is ambiguous -> `killed`. */
1817
+ _kernelDeathFault(err) {
1818
+ if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
1819
+ if (this._sbx.memoryMb !== null)
1820
+ return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
1821
+ return ["killed", "the kernel box exited"];
1822
+ }
1823
+
1770
1824
  _teardownResult(type, message, started) {
1771
1825
  this._kill();
1772
1826
  // Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.17",
3
+ "version": "0.1.20",
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",