kern-sandbox 0.2.0 → 0.2.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.
Files changed (2) hide show
  1. package/index.js +39 -16
  2. package/package.json +1 -1
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.2.0";
39
+ const VERSION = "0.2.1";
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
@@ -962,15 +962,37 @@ const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
962
962
  * byte 2 = the OOM outcome: 1 iff the kernel's OOM killer fired against this box's own cgroup
963
963
  * byte 3 = the signal that terminated the workload, 0 if it exited on its own
964
964
  *
965
- * A SHORT buffer is an OLDER kern, not a malformed one: every absent byte reads as its "undetermined"
966
- * value (0, and `null` for the signal, which must stay distinguishable from "nothing killed it").
967
- * Mirrors `_parse_started_bytes`. */
965
+ * A SHORT buffer is an OLDER kern, not a malformed one, and the two OUTCOME bytes read `null` when
966
+ * absent rather than 0: for them "kern did not say" and "kern said no" are different facts and a caller
967
+ * acts differently on each. The enforcement byte keeps 0, which already spells "undetermined".
968
+ *
969
+ * THE OOM BYTE LEARNED THIS THE EXPENSIVE WAY: it returned 0 for both, so an older binary could only be
970
+ * supported by reading the stderr sentence whenever the byte was not 1, including against a binary that
971
+ * had just said 0. See `oomVerdict`. Mirrors `_parse_started_bytes`. */
972
+ /** Did the kernel's OOM killer take this box? The ONE place the byte and the sentence are combined.
973
+ *
974
+ * Three states, each naming what the SUBJECT did: the byte arrived, so it decides and the sentence is
975
+ * not read; no byte and kern wrote NOTHING, so kern never reached the teardown where it would have
976
+ * printed the sentence either and an OOM line in this stderr is the workload's own text; no byte but a
977
+ * payload was written, so this is a binary older than the byte reporting through its only channel.
978
+ *
979
+ * MEASURED on 2026-09-12 with the four-byte binary, when these were combined by `||`: a cell that wrote
980
+ * kern's own OOM sentence to stderr and was then stopped from outside came back `fault=oom` while the
981
+ * third byte said 0 - the inverted verdict the byte exists to close, re-opened by the sandboxed code in
982
+ * one line. Preferring the byte was not enough on its own: an outside kill takes the box BEFORE
983
+ * teardown, so a new binary also arrives with no byte. Mirrors `_oom_verdict`. */
984
+ function oomVerdict(oomSignal, stderr, kernWrotePayload) {
985
+ if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
986
+ if (!kernWrotePayload) return false;
987
+ return kernReportedOom(stderr);
988
+ }
989
+
968
990
  function parseStartedBytes(buf) {
969
991
  const b = buf || Buffer.alloc(0);
970
992
  return {
971
993
  boxStarted: b.length >= 1 && b[0] === 1,
972
994
  capSignal: b.length >= 2 ? b[1] : 0,
973
- oomSignal: b.length >= 3 ? b[2] : 0,
995
+ oomSignal: b.length >= 3 ? b[2] : null,
974
996
  workloadSignal: b.length >= 4 ? b[3] : null,
975
997
  };
976
998
  }
@@ -1569,7 +1591,7 @@ class Sandbox {
1569
1591
  let child;
1570
1592
  let boxStarted = false;
1571
1593
  let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
1572
- let oomSignal = 0; // 3rd started byte: 1 = the kernel OOM-killed this box's own cgroup, 0 = it did not
1594
+ let oomSignal = null; // 3rd started byte: 1 = OOM-killed, 0 = not, null = this kern does not say
1573
1595
  let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
1574
1596
  try {
1575
1597
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
@@ -1650,6 +1672,7 @@ class Sandbox {
1650
1672
  const rc = toRc(code, signal);
1651
1673
  let fault = this._classify(
1652
1674
  rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
1675
+ boxStarted,
1653
1676
  );
1654
1677
  const execFail = execFailureBinary(stderr);
1655
1678
  if (execFail !== null && rc !== 0) {
@@ -1749,8 +1772,8 @@ class Sandbox {
1749
1772
  }
1750
1773
 
1751
1774
  _classify(
1752
- rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = 0, aliveState = ALIVE_UNKNOWN,
1753
- workloadSignal = null,
1775
+ rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = null, aliveState = ALIVE_UNKNOWN,
1776
+ workloadSignal = null, kernWrotePayload = false,
1754
1777
  ) {
1755
1778
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
1756
1779
  // heuristic, because stderr is a channel the workload controls.
@@ -1795,7 +1818,7 @@ class Sandbox {
1795
1818
  // memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
1796
1819
  // 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
1797
1820
  // kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
1798
- if (oomSignal === 1 || kernReportedOom(stderr))
1821
+ if (oomVerdict(oomSignal, stderr, kernWrotePayload))
1799
1822
  return sandboxFault(
1800
1823
  "oom",
1801
1824
  "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
@@ -2569,8 +2592,8 @@ class Kernel {
2569
2592
  * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
2570
2593
  * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
2571
2594
  * "your cap was not in force here" is the one thing the caller cannot find out for itself. */
2572
- _kernelDeathFault(err, capSignal = 0, oomSignal = 0) {
2573
- if (oomSignal === 1 || kernReportedOom(err)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
2595
+ _kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false) {
2596
+ if (oomVerdict(oomSignal, err, kernWrotePayload)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
2574
2597
  if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
2575
2598
  if (capSignal === 2)
2576
2599
  return [
@@ -2593,7 +2616,7 @@ class Kernel {
2593
2616
  * falls back to kern's stderr sentence. */
2594
2617
  async _readCapSignal() {
2595
2618
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2596
- if (!ch) return [0, 0];
2619
+ if (!ch) return [0, null, false];
2597
2620
  if (this._startedSig.length < 3 && !ch.destroyed) {
2598
2621
  await new Promise((res) => {
2599
2622
  const t = setTimeout(res, 1000);
@@ -2601,8 +2624,8 @@ class Kernel {
2601
2624
  ch.once("error", () => { clearTimeout(t); res(); });
2602
2625
  });
2603
2626
  }
2604
- const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2605
- return [capSignal, oomSignal];
2627
+ const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2628
+ return [capSignal, oomSignal, boxStarted];
2606
2629
  }
2607
2630
 
2608
2631
  _teardownResult(type, message, started) {
@@ -2965,14 +2988,14 @@ class WarmBox {
2965
2988
  fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
2966
2989
  });
2967
2990
  }
2968
- const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2991
+ const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2969
2992
  this.retire();
2970
2993
  let type = "killed";
2971
2994
  let dflt = "the box exited before the code finished";
2972
2995
  // Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
2973
2996
  // the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
2974
2997
  // what keeps a pool box's OOM from being thrown as a box that never came up.
2975
- if (oomSignal === 1 || kernReportedOom(err)) {
2998
+ if (oomVerdict(oomSignal, err, boxStarted)) {
2976
2999
  type = "oom";
2977
3000
  dflt = "the box exceeded its memory cap and was OOM-killed";
2978
3001
  } else if (looksLikeStartupFailure(err)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",