kern-sandbox 0.1.20 → 0.1.22

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/index.js +63 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -98,8 +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
- | `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 |
101
+ | `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 |
102
+ | `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 |
103
103
 
104
104
  A box that fails to **start** (kern exits 125: a mount refused at runtime, an unmappable `--user`, a
105
105
  seccomp/AppArmor/cgroup setup error, or a pull/image error) is **thrown** as a `SandboxError`, not
@@ -140,7 +140,7 @@ new Sandbox({
140
140
  timeoutS, // default 30, MANDATORY per-call deadline
141
141
  network, // default false (RELAXES ISOLATION)
142
142
  capDrop, // default ["ALL"]: capabilities dropped from every box. kern always drops
143
- // 14 dangerous ones; this drops the rest, which were held over the box's own
143
+ // 16 dangerous ones; this drops the rest, which were held over the box's own
144
144
  // user namespace. Pass [] to keep them (needed only if the workload binds a
145
145
  // port below 1024 INSIDE the box).
146
146
  mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
package/index.js CHANGED
@@ -1043,6 +1043,7 @@ class Sandbox {
1043
1043
  return new Promise((resolve, reject) => {
1044
1044
  let child;
1045
1045
  let boxStarted = false;
1046
+ let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
1046
1047
  try {
1047
1048
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
1048
1049
  // The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
@@ -1058,9 +1059,11 @@ class Sandbox {
1058
1059
 
1059
1060
  const startedCh = child.stdio[3];
1060
1061
  if (startedCh) {
1061
- // One byte (0x01) = the box started; stream end with no byte = never started / old kern.
1062
+ // Byte 0 (0x01) = the box started; stream end with no byte = never started / old kern. Byte 1
1063
+ // (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0.
1062
1064
  startedCh.on("data", (b) => {
1063
1065
  if (b.length && b[0] === 1) boxStarted = true;
1066
+ if (b.length >= 2) capSignal = b[1];
1064
1067
  });
1065
1068
  startedCh.on("error", () => {});
1066
1069
  }
@@ -1087,7 +1090,7 @@ class Sandbox {
1087
1090
  const stdout = out.buffer().toString("utf8");
1088
1091
  const stderr = err.buffer().toString("utf8");
1089
1092
  const rc = toRc(code, signal);
1090
- let fault = this._classify(rc, signal, stderr, timedOut, timeoutS);
1093
+ let fault = this._classify(rc, signal, stderr, timedOut, timeoutS, capSignal);
1091
1094
  if (boxStarted && fault && fault.type === "startup_failed") {
1092
1095
  // kern signalled the box STARTED, so a `startup_failed` here is only the stderr heuristic
1093
1096
  // matching a marker the WORKLOAD wrote (code-based faults are decided first). The box
@@ -1161,7 +1164,7 @@ class Sandbox {
1161
1164
  }
1162
1165
  }
1163
1166
 
1164
- _classify(rc, signal, stderr, timedOut, timeoutS) {
1167
+ _classify(rc, signal, stderr, timedOut, timeoutS, capSignal = 0) {
1165
1168
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
1166
1169
  // heuristic, because stderr is a channel the workload controls.
1167
1170
  if (timedOut)
@@ -1173,11 +1176,19 @@ class Sandbox {
1173
1176
  return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
1174
1177
  if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
1175
1178
  // 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)
1179
+ // sets memory.oom.group=1, so the whole box dies at once). `capSignal` is kern's UNFORGEABLE
1180
+ // enforcement byte (2nd byte of KERN_STARTED_FD, not the workload's stderr): 1 = enforced, 2 =
1181
+ // requested but NOT enforced here, 0 = undetermined (old kern / no --memory). Claim `oom` when a
1182
+ // --memory cap was set AND kern did not report it unenforced (capSignal !== 2): enforced (1) is a
1183
+ // certain cgroup OOM, undetermined (0) keeps the pre-signal heuristic. When kern reports the cap did
1184
+ // not bind (2), a SIGKILL cannot be attributed to the box's cgroup - keep the honest `killed`.
1185
+ if (this.memoryMb !== null && capSignal !== 2)
1180
1186
  return sandboxFault("oom", "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)");
1187
+ if (capSignal === 2)
1188
+ return sandboxFault(
1189
+ "killed",
1190
+ "the box was SIGKILLed, but its memory cap was not enforced here (no cgroup delegation), so it is not attributed to a cgroup OOM",
1191
+ );
1181
1192
  return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
1182
1193
  }
1183
1194
  if (rc === EXIT_SIGTERM || signal === "SIGTERM")
@@ -1644,6 +1655,10 @@ class Kernel {
1644
1655
  this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
1645
1656
  this._stderr = Buffer.alloc(0);
1646
1657
  this._dead = false;
1658
+ // kern's memory-cap enforcement byte (2nd byte of KERN_STARTED_FD). For a RESIDENT box kern writes
1659
+ // it only at box teardown (a cell kills the kernel), so it arrives ~concurrent with the death we
1660
+ // detect on stdout; read once, bounded, on death (`_readCapSignal`). 0 = undetermined / old kern.
1661
+ this._capSignal = 0;
1647
1662
  }
1648
1663
 
1649
1664
  async _open() {
@@ -1655,14 +1670,21 @@ class Kernel {
1655
1670
  this._name = uniqueName();
1656
1671
  this._childEnv = { ...process.env };
1657
1672
  if (!sbx.enforceLimits) this._childEnv.KERN_NO_SCOPE = "1";
1673
+ this._childEnv.KERN_STARTED_FD = "3"; // same unforgeable channel; here for the enforcement byte only
1658
1674
  const argv = [
1659
1675
  ...sbx._baseArgv(this._name, { network: sbx.network, timeoutS: KERNEL_BACKSTOP_S }),
1660
1676
  "--", "python3", "-S", `${WORKSPACE}/${this._driver}`,
1661
1677
  ];
1662
- // detached: own process group so we can killpg the box + kern as a unit, like _spawn.
1678
+ // detached: own process group so we can killpg the box + kern as a unit, like _spawn. fd 3 carries
1679
+ // the started/enforcement bytes; the workload never holds it.
1663
1680
  this._child = spawn(argv[0], argv.slice(1), {
1664
- env: this._childEnv, detached: true, stdio: ["pipe", "pipe", "pipe"],
1681
+ env: this._childEnv, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"],
1665
1682
  });
1683
+ const startedCh = this._child.stdio[3];
1684
+ if (startedCh) {
1685
+ startedCh.on("data", (b) => { if (b.length >= 2) this._capSignal = b[1]; });
1686
+ startedCh.on("error", () => {});
1687
+ }
1666
1688
  this._child.on("error", () => { this._dead = true; this._flush(null); });
1667
1689
  this._child.on("close", () => { this._dead = true; this._flush(null); });
1668
1690
  this._child.stdout.on("data", (d) => this._onData(d));
@@ -1761,7 +1783,7 @@ class Kernel {
1761
1783
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
1762
1784
  if (reply === null) {
1763
1785
  const err = this._stderr.toString("utf8");
1764
- const [kind, dflt] = this._kernelDeathFault(err);
1786
+ const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
1765
1787
  return this._teardownResult(kind, err.trim() || dflt, started);
1766
1788
  }
1767
1789
  return this._resultFromReply(reply, started);
@@ -1809,18 +1831,42 @@ class Kernel {
1809
1831
  }
1810
1832
 
1811
1833
  /** 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) {
1834
+ * stderr means it never came up (startup_failed). Otherwise this is the runCode counterpart of the
1835
+ * one-shot _classify SIGKILL branch - a kernel death has no per-cell exit code. `capSignal` is kern's
1836
+ * unforgeable enforcement byte (0 = old kern / undetermined, 1 = memory cap enforced, 2 = requested
1837
+ * but NOT enforced): with a memoryMb cap AND not-reported-unenforced (`!== 2`), the cgroup OOM-killer
1838
+ * is the cause -> `oom`; when kern reports the cap did not bind (2), the kill is not attributable to
1839
+ * the box's cgroup -> `killed`; uncapped is also `killed`. */
1840
+ _kernelDeathFault(err, capSignal = 0) {
1818
1841
  if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
1819
- if (this._sbx.memoryMb !== null)
1842
+ if (this._sbx.memoryMb !== null && capSignal !== 2)
1820
1843
  return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
1844
+ if (capSignal === 2)
1845
+ return [
1846
+ "killed",
1847
+ "the kernel box was SIGKILLed, but its memory cap was not enforced here (no cgroup delegation), so it is not attributed to a cgroup OOM",
1848
+ ];
1821
1849
  return ["killed", "the kernel box exited"];
1822
1850
  }
1823
1851
 
1852
+ /** kern's memory-cap enforcement byte for the resident box, read ONCE on kernel death. kern writes
1853
+ * the two-byte KERN_STARTED_FD signal only at box teardown (a resident box exits when a cell kills it),
1854
+ * ~concurrent with the death detected on stdout. The fd-3 `data` handler in `_open` records the byte
1855
+ * as it arrives; this awaits a BOUNDED window (the fd's own `end`, or 1 s) so the read is deterministic
1856
+ * rather than a race, then returns the byte (0 = EOF / old kern / not yet -> the memoryMb heuristic). */
1857
+ async _readCapSignal() {
1858
+ const ch = this._child && this._child.stdio && this._child.stdio[3];
1859
+ if (!ch) return 0;
1860
+ if (this._capSignal === 0 && !ch.destroyed) {
1861
+ await new Promise((res) => {
1862
+ const t = setTimeout(res, 1000);
1863
+ ch.once("end", () => { clearTimeout(t); res(); });
1864
+ ch.once("error", () => { clearTimeout(t); res(); });
1865
+ });
1866
+ }
1867
+ return this._capSignal;
1868
+ }
1869
+
1824
1870
  _teardownResult(type, message, started) {
1825
1871
  this._kill();
1826
1872
  // 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.20",
3
+ "version": "0.1.22",
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",