kern-sandbox 0.2.0 → 0.2.2

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 +51 -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.2";
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,39 @@ 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. The remaining bound is measured: against v0.9.32
984
+ * a cell that writes the sentence and then chooses `exit 137` reports `oom`, and without the sentence
985
+ * `killed`. Mirrors `_oom_verdict`. */
986
+ function oomVerdict(oomSignal, stderr, kernWrotePayload) {
987
+ if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
988
+ if (!kernWrotePayload) return false;
989
+ return kernReportedOom(stderr);
990
+ }
991
+
968
992
  function parseStartedBytes(buf) {
969
993
  const b = buf || Buffer.alloc(0);
970
994
  return {
971
995
  boxStarted: b.length >= 1 && b[0] === 1,
972
996
  capSignal: b.length >= 2 ? b[1] : 0,
973
- oomSignal: b.length >= 3 ? b[2] : 0,
997
+ oomSignal: b.length >= 3 ? b[2] : null,
974
998
  workloadSignal: b.length >= 4 ? b[3] : null,
975
999
  };
976
1000
  }
@@ -1447,6 +1471,16 @@ class Sandbox {
1447
1471
  * that will never exist would both litter the workspace and collide with itself. A dry argv is for
1448
1472
  * COMPARING, never for running. */
1449
1473
  _baseArgv(name, { network, timeoutS, isSetup = false, dry = false }) {
1474
+ // IDENTITY IS RE-ASSERTED PER BOX, not once per Sandbox, and an external reviewer is the reason. He
1475
+ // overwrote the verified binary IN PLACE with `/bin/true` while a Sandbox was open: the next call
1476
+ // correctly refused to call an empty run a success, and the message it refused with quoted the
1477
+ // version from the FIRST verification - stating that a file which now prints `true (GNU coreutils)
1478
+ // 9.4` had "reported 'kern v0.9.32-48-gb578943'". The verdict was right and the sentence was false.
1479
+ // Re-verifying here rather than repairing the sentence: the binary about to run was no longer the
1480
+ // binary that was checked. The memo is keyed on (realpath, dev, ino, size, mtimeMs), so an unchanged
1481
+ // file costs one stat and a map lookup, and a changed one pays a `--version` and is refused by name.
1482
+ // Mirrors the same call in `_base_argv`.
1483
+ if (!dry) verifyIsKern(this._kern);
1450
1484
  const argv = [
1451
1485
  this._kern, "box", name, "--image", this.image, "--ro",
1452
1486
  "-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
@@ -1569,7 +1603,7 @@ class Sandbox {
1569
1603
  let child;
1570
1604
  let boxStarted = false;
1571
1605
  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
1606
+ let oomSignal = null; // 3rd started byte: 1 = OOM-killed, 0 = not, null = this kern does not say
1573
1607
  let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
1574
1608
  try {
1575
1609
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
@@ -1650,6 +1684,7 @@ class Sandbox {
1650
1684
  const rc = toRc(code, signal);
1651
1685
  let fault = this._classify(
1652
1686
  rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
1687
+ boxStarted,
1653
1688
  );
1654
1689
  const execFail = execFailureBinary(stderr);
1655
1690
  if (execFail !== null && rc !== 0) {
@@ -1749,8 +1784,8 @@ class Sandbox {
1749
1784
  }
1750
1785
 
1751
1786
  _classify(
1752
- rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = 0, aliveState = ALIVE_UNKNOWN,
1753
- workloadSignal = null,
1787
+ rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = null, aliveState = ALIVE_UNKNOWN,
1788
+ workloadSignal = null, kernWrotePayload = false,
1754
1789
  ) {
1755
1790
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
1756
1791
  // heuristic, because stderr is a channel the workload controls.
@@ -1795,7 +1830,7 @@ class Sandbox {
1795
1830
  // memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
1796
1831
  // 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
1797
1832
  // kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
1798
- if (oomSignal === 1 || kernReportedOom(stderr))
1833
+ if (oomVerdict(oomSignal, stderr, kernWrotePayload))
1799
1834
  return sandboxFault(
1800
1835
  "oom",
1801
1836
  "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
@@ -2569,8 +2604,8 @@ class Kernel {
2569
2604
  * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
2570
2605
  * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
2571
2606
  * "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"];
2607
+ _kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false) {
2608
+ if (oomVerdict(oomSignal, err, kernWrotePayload)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
2574
2609
  if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
2575
2610
  if (capSignal === 2)
2576
2611
  return [
@@ -2593,7 +2628,7 @@ class Kernel {
2593
2628
  * falls back to kern's stderr sentence. */
2594
2629
  async _readCapSignal() {
2595
2630
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2596
- if (!ch) return [0, 0];
2631
+ if (!ch) return [0, null, false];
2597
2632
  if (this._startedSig.length < 3 && !ch.destroyed) {
2598
2633
  await new Promise((res) => {
2599
2634
  const t = setTimeout(res, 1000);
@@ -2601,8 +2636,8 @@ class Kernel {
2601
2636
  ch.once("error", () => { clearTimeout(t); res(); });
2602
2637
  });
2603
2638
  }
2604
- const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2605
- return [capSignal, oomSignal];
2639
+ const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2640
+ return [capSignal, oomSignal, boxStarted];
2606
2641
  }
2607
2642
 
2608
2643
  _teardownResult(type, message, started) {
@@ -2965,14 +3000,14 @@ class WarmBox {
2965
3000
  fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
2966
3001
  });
2967
3002
  }
2968
- const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
3003
+ const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2969
3004
  this.retire();
2970
3005
  let type = "killed";
2971
3006
  let dflt = "the box exited before the code finished";
2972
3007
  // Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
2973
3008
  // the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
2974
3009
  // what keeps a pool box's OOM from being thrown as a box that never came up.
2975
- if (oomSignal === 1 || kernReportedOom(err)) {
3010
+ if (oomVerdict(oomSignal, err, boxStarted)) {
2976
3011
  type = "oom";
2977
3012
  dflt = "the box exceeded its memory cap and was OOM-killed";
2978
3013
  } 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.2",
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",