kern-sandbox 0.1.43 → 0.2.0

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 +290 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -133,10 +133,10 @@ LangChain tool and the MCP server already use it.
133
133
  |---|---|
134
134
  | `timeout` | the call exceeded `timeoutS`; the binding killed the box |
135
135
  | `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
136
- | `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 |
137
- | `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 |
138
-
136
+ | `oom` | kern reported that the kernel's OOM killer took the box against its own memory cap: a breached `memory.max` takes the whole box, since kern sets `memory.oom.group=1`. Reported on a channel the code in the box cannot write (a third byte on kern's own descriptor), so it is an observation of the kernel's counter rather than a guess from the exit code |
137
+ | `killed` | the box was SIGKILLed with **no** OOM reported against its cap: an external kill (`kern stop`, a signal, the host running out of memory), or a cap that did not bind here (no cgroup delegation, which the message names). A `memoryMb` cap being set is not, by itself, evidence that memory is what killed the box |
139
138
  | `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 |
139
+ | `startup_failed` | returned as data, not thrown, in one case: your `timeoutS` fired while kern was still BUILDING the box, so the code never ran. kern reports on a separate descriptor whether it reached your workload, which is what tells this from a slow cell. A host path that blocks does it: a bind source on a dead NFS export, a FUSE mount whose daemon is gone. A longer timeout does not help |
140
140
 
141
141
  **An enforced `pids` cap produces no fault, and that is deliberate.** When `pids` binds, the refused
142
142
  `fork` returns `EAGAIN`. Code that catches it exits 0, so the call reports `fault: null, success:
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.43";
39
+ const VERSION = "0.2.0";
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
@@ -415,6 +415,12 @@ while True:
415
415
  const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
416
416
  const EXIT_SIGSYS = 159; // SIGSYS: a seccomp-denied syscall = a blocked escape attempt
417
417
  const EXIT_SIGTERM = 143; // SIGTERM: kern's --timeout backstop reaping the box
418
+ // The signal NUMBERS behind those codes, for kern's 4th started-byte. Spelled out rather than derived
419
+ // from the code, because `128 + N` is the convention being checked and deriving N from it would make the
420
+ // check circular.
421
+ const SIG_KILL = 9;
422
+ const SIG_TERM = 15;
423
+ const SIG_SYS = 31;
418
424
 
419
425
  // Per-call kwargs that DEFAULT to the Sandbox value: UNSET means "inherit the constructor's", whereas
420
426
  // an explicit `null` means "disable" (used for onStdout/onStderr overrides).
@@ -562,7 +568,59 @@ function sandboxFault(type, message) {
562
568
  return { type, message };
563
569
  }
564
570
 
565
- /** Locate `kern`: $KERN_BIN if set, else the first `kern` on $PATH. */
571
+ /** Binaries already identified as kern, keyed by identity and not by path: a `kern` REPLACED between two
572
+ * calls is a different program and gets checked again. */
573
+ const VERIFIED_KERN = new Set();
574
+
575
+ /** Refuse a binary that does not IDENTIFY ITSELF as kern. Throws `SandboxError` if it does not.
576
+ *
577
+ * MEASURED, and found by an external reviewer running the positive control this project wrote for him:
578
+ * with `KERN_BIN=/bin/true` a call returned `success: true, exitCode: 0, fault: null` and an empty
579
+ * stdout. The code never ran and the caller was told it had. Any `kern` earlier in `PATH` that is not
580
+ * kern does this: a leftover wrapper, a shim, a no-op. An agent loop reads `success` and every
581
+ * conclusion after that is about a program that never executed.
582
+ *
583
+ * POSITIVE IDENTIFICATION, not inference from a missing signal: a kern old enough to predate
584
+ * `KERN_STARTED_FD` writes no bytes either, and refusing it would punish an old binary rather than a
585
+ * fake one. `kern --version` prints `kern <version>`, a prefix kern's own suite asserts.
586
+ *
587
+ * Memoised per binary identity, so it costs one `--version` (measured at 0.9 ms) per distinct binary
588
+ * per process and nothing afterwards. Fail-closed: unrunnable, slow or unrecognised is refused, because
589
+ * an unverifiable runtime is the case this exists for. Mirrors `_verify_is_kern`. */
590
+ function verifyIsKern(bin) {
591
+ let key;
592
+ try {
593
+ const st = fs.statSync(bin);
594
+ key = [fs.realpathSync(bin), st.dev, st.ino, st.size, st.mtimeMs].join("|");
595
+ } catch (e) {
596
+ throw new SandboxError(`could not stat the kern binary at '${bin}': ${e.message}`);
597
+ }
598
+ if (VERIFIED_KERN.has(key)) return;
599
+ const hint =
600
+ "If this is not the kern you meant, set $KERN_BIN to the right path. To install kern:\n" +
601
+ " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh";
602
+ // Generous on purpose: a loaded machine must not be told its kern is fake. `--version` writes one
603
+ // line, so a binary that cannot answer in ten seconds is not one to trust with a box.
604
+ const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 10000 });
605
+ if (out.error && out.error.code === "ETIMEDOUT")
606
+ throw new SandboxError(
607
+ `'${bin}' did not answer \`--version\` within 10s, so it cannot be identified as kern. ${hint}`,
608
+ );
609
+ if (out.error) throw new SandboxError(`could not run '${bin} --version': ${out.error.message}. ${hint}`);
610
+ const first = String(out.stdout || "").trim().split("\n")[0] || "";
611
+ if (out.status !== 0 || !first.startsWith("kern ")) {
612
+ const shown = first ? JSON.stringify(first.slice(0, 120)) : "(no output)";
613
+ throw new SandboxError(
614
+ `'${bin}' is not kern: \`${bin} --version\` exited ${out.status} and printed ${shown}, where kern ` +
615
+ "prints a line beginning 'kern '. Refusing to run code, because a binary that is not kern would " +
616
+ `return an EMPTY, SUCCESSFUL result for every call and the code would never run. ${hint}`,
617
+ );
618
+ }
619
+ VERIFIED_KERN.add(key);
620
+ }
621
+
622
+ /** Locate `kern`: $KERN_BIN if set, else the first `kern` on $PATH. The result is also IDENTIFIED as
623
+ * kern (see `verifyIsKern`): being executable and being named `kern` are not the same as being kern. */
566
624
  function findKern() {
567
625
  const env = process.env.KERN_BIN;
568
626
  if (env) {
@@ -572,6 +630,7 @@ function findKern() {
572
630
  } catch {
573
631
  throw new SandboxError(`$KERN_BIN='${env}' is not an executable file`);
574
632
  }
633
+ verifyIsKern(env);
575
634
  return env;
576
635
  }
577
636
  const exts = [""];
@@ -581,7 +640,10 @@ function findKern() {
581
640
  const cand = path.join(d, "kern" + ext);
582
641
  try {
583
642
  fs.accessSync(cand, fs.constants.X_OK);
584
- if (fs.statSync(cand).isFile()) return cand;
643
+ if (fs.statSync(cand).isFile()) {
644
+ verifyIsKern(cand);
645
+ return cand;
646
+ }
585
647
  } catch {
586
648
  /* keep looking */
587
649
  }
@@ -884,7 +946,74 @@ function isKernDiagnostic(line) {
884
946
  return KERN_DIAGNOSTICS.some((p) => s.startsWith(p));
885
947
  }
886
948
 
949
+ /** The sentence kern prints when it has READ the kernel's OOM counter for this box's own cgroup. A
950
+ * contract between two programs: if kern rewords it, this stops recognising a real OOM and starts
951
+ * reporting `killed` - wrong in the safe direction, still wrong. Mirrors `_KERN_OOM_MARKER`. */
952
+ const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
887
953
 
954
+ /** kern's KERN_STARTED_FD payload, as `{ boxStarted, capSignal, oomSignal, workloadSignal }`.
955
+ *
956
+ * THE WIRE FORMAT IS SPELLED HERE AND NOWHERE ELSE, because it grew twice in one day (the OOM outcome,
957
+ * then the workload's signal) and each time a reader with its own copy of the layout was left behind.
958
+ * Three places in this file read those bytes; they now all read them through this.
959
+ *
960
+ * byte 0 = the box started (kern reached its `Ok` arm with a code that is not 125)
961
+ * byte 1 = the memory-cap enforcement signal: 0 undetermined, 1 enforced, 2 requested but not enforced
962
+ * byte 2 = the OOM outcome: 1 iff the kernel's OOM killer fired against this box's own cgroup
963
+ * byte 3 = the signal that terminated the workload, 0 if it exited on its own
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`. */
968
+ function parseStartedBytes(buf) {
969
+ const b = buf || Buffer.alloc(0);
970
+ return {
971
+ boxStarted: b.length >= 1 && b[0] === 1,
972
+ capSignal: b.length >= 2 ? b[1] : 0,
973
+ oomSignal: b.length >= 3 ? b[2] : 0,
974
+ workloadSignal: b.length >= 4 ? b[3] : null,
975
+ };
976
+ }
977
+
978
+ /** The byte kern writes to `KERN_ALIVE_FD` the moment it accepts the descriptor, before any box setup.
979
+ * It is what tells "the setup has not finished" from "this binary does not speak the protocol": an older
980
+ * kern never writes to that pipe AND never closes it, so the pipe is open and silent in both cases.
981
+ * Mirrors `_ALIVE_ACK`. */
982
+ const ALIVE_ACK = 0x41; // 'A'
983
+
984
+ /** Where kern was when the deadline fired, read off the `KERN_ALIVE_FD` pipe. Spelled once so a caller
985
+ * cannot invent a fourth answer. Mirrors the `_ALIVE_*` constants in the Python binding. */
986
+ const ALIVE_PAST_SETUP = "past-setup"; // the workload ran (EOF at execvp), or kern reported setup failed
987
+ const ALIVE_IN_SETUP = "in-setup"; // kern acknowledged the channel and is still BUILDING the box
988
+ const ALIVE_UNKNOWN = "unknown"; // nothing on the pipe: a kern that predates this channel
989
+
990
+ /** True iff KERN said the kernel's OOM killer took this box against its own memory cap.
991
+ *
992
+ * The ONE definition of "this was an OOM", used by all three death paths (the one-shot exit-code
993
+ * classifier, the resident kernel's death, and a pool box that died) so they cannot drift into
994
+ * disagreeing about the same box. It is an OBSERVATION - kern reads `memory.events` and says so -
995
+ * where the SDK can only infer, and the inference it replaced was MEASURED wrong in both directions:
996
+ * `kern stop` during a cell was reported `oom`, while a real OOM on the resident kernel was reported
997
+ * as a box that failed to start (and raised).
998
+ *
999
+ * Anchored on kern's `kern:` line prefix, which the real line carries (measured verbatim: `kern: the
1000
+ * workload was killed by the kernel's OOM killer against this box's own memory cap.`), MINUS the
1001
+ * benign diagnostics - `kern: note: <quoting the sentence>` is kern TALKING about an OOM, not
1002
+ * reporting one.
1003
+ *
1004
+ * THE FALLBACK, NOT THE AUTHORITY. Against a kern that writes the 3rd KERN_STARTED_FD byte the verdict
1005
+ * comes from there instead, on a pipe the workload never holds. This covers an older binary, and it is
1006
+ * forgeable in exactly one direction: a workload that writes the whole prefixed sentence itself turns its
1007
+ * own `killed` into `oom`. Both are sandbox faults, and timeout / blocked-escape are decided by exit code
1008
+ * before any text is read, so the worst case is a caller misleading itself about its own kill.
1009
+ * Mirrors `_kern_reported_oom`. */
1010
+ function kernReportedOom(stderr) {
1011
+ for (const line of String(stderr || "").split("\n")) {
1012
+ const s = line.replace(/^\s+/, "");
1013
+ if (s.startsWith("kern:") && !isKernDiagnostic(s) && s.includes(KERN_OOM_MARKER)) return true;
1014
+ }
1015
+ return false;
1016
+ }
888
1017
 
889
1018
  function looksLikeStartupFailure(stderr) {
890
1019
  const markers = [
@@ -898,9 +1027,13 @@ function looksLikeStartupFailure(stderr) {
898
1027
  "error: oci:",
899
1028
  "error: image:",
900
1029
  ];
1030
+ // The OOM sentence is skipped for a sharper reason than the benign notes: it is a report about a box
1031
+ // that RAN, and it is `kern:`-prefixed, so it used to satisfy this predicate. MEASURED, that is how a
1032
+ // real OOM on a resident kernel came back as `startup_failed` and was THROWN instead of returning an
1033
+ // `oom` fault.
901
1034
  for (const line of stderr.split("\n")) {
902
1035
  const s = line.replace(/^\s+/, "");
903
- if (isKernDiagnostic(s)) continue;
1036
+ if (isKernDiagnostic(s) || kernReportedOom(s)) continue;
904
1037
  if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
905
1038
  }
906
1039
  return false;
@@ -1424,32 +1557,71 @@ class Sandbox {
1424
1557
  // OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
1425
1558
  // compatible).
1426
1559
  childEnv.KERN_STARTED_FD = "3";
1560
+ // A SECOND, LIVE channel, because the first one is post-mortem. kern writes KERN_STARTED_FD at the
1561
+ // box's TEARDOWN, so when OUR deadline fires we kill kern before that write and learn nothing: a
1562
+ // workload that was slow and a kern whose SETUP blocked are the same overrun. fd 4 carries kern's
1563
+ // readiness pipe: the ack byte on acceptance, EOF when the workload `execvp`s (the box child marks it
1564
+ // FD_CLOEXEC), one byte if setup or exec failed, and nothing at all from a kern that predates it.
1565
+ childEnv.KERN_ALIVE_FD = "4";
1427
1566
 
1428
1567
  const started = process.hrtime.bigint();
1429
1568
  return new Promise((resolve, reject) => {
1430
1569
  let child;
1431
1570
  let boxStarted = false;
1432
1571
  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
1573
+ let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
1433
1574
  try {
1434
1575
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
1435
1576
  // The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
1436
1577
  child = spawn(argv[0], argv.slice(1), {
1437
1578
  env: childEnv,
1438
1579
  detached: true,
1439
- stdio: ["ignore", "pipe", "pipe", "pipe"],
1580
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
1440
1581
  });
1441
1582
  } catch (e) {
1442
1583
  this._removeEnvFile(name);
1443
1584
  return reject(new SandboxError(`could not spawn the box: ${e.message}`));
1444
1585
  }
1445
1586
 
1587
+ // The alive channel's state, updated as the pipe speaks. Read at the deadline, BEFORE the kill:
1588
+ // the teardown closes every write end, so afterwards the pipe reads EOF whatever the box was doing
1589
+ // and the question answers itself wrongly.
1590
+ let aliveState = ALIVE_UNKNOWN;
1591
+ const aliveCh = child.stdio[4];
1592
+ if (aliveCh) {
1593
+ aliveCh.on("data", (b) => {
1594
+ if (aliveState === ALIVE_PAST_SETUP) return;
1595
+ for (const byte of b) {
1596
+ if (byte === ALIVE_ACK) {
1597
+ if (aliveState === ALIVE_UNKNOWN) aliveState = ALIVE_IN_SETUP;
1598
+ } else {
1599
+ aliveState = ALIVE_PAST_SETUP; // a byte beyond the ack: kern said setup failed
1600
+ return;
1601
+ }
1602
+ }
1603
+ });
1604
+ // EOF: the box child's FD_CLOEXEC closed it at `execvp`, so the workload ran.
1605
+ aliveCh.on("end", () => {
1606
+ aliveState = ALIVE_PAST_SETUP;
1607
+ });
1608
+ aliveCh.on("error", () => {});
1609
+ }
1446
1610
  const startedCh = child.stdio[3];
1447
1611
  if (startedCh) {
1448
1612
  // Byte 0 (0x01) = the box started; stream end with no byte = never started / old kern. Byte 1
1449
- // (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0.
1613
+ // (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0. Byte 2
1614
+ // (a NEWER kern still) = the OOM OUTCOME: 1 iff the kernel's OOM killer fired against this box's
1615
+ // OWN cgroup. Enforcement is not an outcome, and this byte is the only place the outcome arrives
1616
+ // on a channel the workload cannot write.
1617
+ // ACCUMULATED rather than read off one chunk: kern's write is atomic, so all four bytes arrive
1618
+ // together in practice, but a stream that split them would silently cost us the last one - and a
1619
+ // lost OOM byte reads as "no OOM", a lost signal byte as "nothing killed it", both wrong answers
1620
+ // arrived at invisibly.
1621
+ let sig = Buffer.alloc(0);
1450
1622
  startedCh.on("data", (b) => {
1451
- if (b.length && b[0] === 1) boxStarted = true;
1452
- if (b.length >= 2) capSignal = b[1];
1623
+ sig = Buffer.concat([sig, b]);
1624
+ ({ boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(sig));
1453
1625
  });
1454
1626
  startedCh.on("error", () => {});
1455
1627
  }
@@ -1476,7 +1648,9 @@ class Sandbox {
1476
1648
  const stdout = out.buffer().toString("utf8");
1477
1649
  const stderr = err.buffer().toString("utf8");
1478
1650
  const rc = toRc(code, signal);
1479
- let fault = this._classify(rc, signal, stderr, timedOut, timeoutS, capSignal);
1651
+ let fault = this._classify(
1652
+ rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
1653
+ );
1480
1654
  const execFail = execFailureBinary(stderr);
1481
1655
  if (execFail !== null && rc !== 0) {
1482
1656
  // BEFORE the suppression below, which would erase it: the box started, so that branch
@@ -1574,37 +1748,75 @@ class Sandbox {
1574
1748
  }
1575
1749
  }
1576
1750
 
1577
- _classify(rc, signal, stderr, timedOut, timeoutS, capSignal = 0) {
1751
+ _classify(
1752
+ rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = 0, aliveState = ALIVE_UNKNOWN,
1753
+ workloadSignal = null,
1754
+ ) {
1578
1755
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
1579
1756
  // heuristic, because stderr is a channel the workload controls.
1580
- if (timedOut)
1757
+ if (timedOut) {
1758
+ // AND THE DEADLINE ALONE DOES NOT SAY WHOSE FAULT IT WAS. `ALIVE_IN_SETUP` is kern's own answer,
1759
+ // read off the readiness pipe while kern was still alive: the box was still being BUILT, so the
1760
+ // code never ran and calling this a `timeout` would tell the caller their workload was slow. That
1761
+ // class was measured with a FIFO volume source (404 seconds in `wait_for_partner`), and the shapes
1762
+ // behind it - an `lstat` on a dead NFS mount, a FUSE whose daemon is gone - are not FIFOs and
1763
+ // cannot be refused by type. Every other state keeps the old verdict, `ALIVE_UNKNOWN` (an older
1764
+ // kern) included: absence of evidence is not evidence.
1765
+ if (aliveState === ALIVE_IN_SETUP)
1766
+ return sandboxFault(
1767
+ "startup_failed",
1768
+ `the box never started: kern was still setting it up when the ${timeoutS ?? this.timeoutS}s ` +
1769
+ "deadline fired, so the code never ran. A host path that blocks is what does this - a bind " +
1770
+ "source on a dead NFS or a FUSE mount whose daemon is gone, an image layer on a stalled " +
1771
+ "disk - and the remedy is that path, not a longer timeout",
1772
+ );
1581
1773
  return sandboxFault(
1582
1774
  "timeout",
1583
1775
  `exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
1584
1776
  );
1585
- if (rc === EXIT_SIGSYS || signal === "SIGSYS")
1777
+ }
1778
+ // THE EXIT CODE IS THE RIGHT THING TO PROPAGATE AND THE WRONG THING TO CLASSIFY FROM: kern reports
1779
+ // the workload's status as `128 + N`, so a workload the kernel killed and one that called `exit(137)`
1780
+ // are the same number. MEASURED through the Python binding: `sys.exit(137)` came back `killed` with a
1781
+ // message about an external kill that never happened, and `sys.exit(159)` came back `escape_blocked`,
1782
+ // a security event a cell could fabricate in one line. kern's 4th started-byte carries the signal.
1783
+ // `null` (an older kern, or a kern our teardown killed first) keeps the old exit-code reading:
1784
+ // absence of evidence is not evidence. `signal === "SIG..."` is a different question, kern ITSELF
1785
+ // being signalled, and stays as it was.
1786
+ const killedBy = (n) => workloadSignal === null || workloadSignal === n;
1787
+ if ((rc === EXIT_SIGSYS && killedBy(SIG_SYS)) || signal === "SIGSYS")
1586
1788
  return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
1587
- if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
1588
- // A memory-capped box SIGKILLed is the cgroup OOM-killer - what a breached memory.max does (kern
1589
- // sets memory.oom.group=1, so the whole box dies at once). `capSignal` is kern's UNFORGEABLE
1590
- // enforcement byte (2nd byte of KERN_STARTED_FD, not the workload's stderr): 1 = enforced, 2 =
1591
- // requested but NOT enforced here, 0 = undetermined (old kern / no --memory). Claim `oom` when a
1592
- // --memory cap was set AND kern did not report it unenforced (capSignal !== 2): enforced (1) is a
1593
- // certain cgroup OOM, undetermined (0) keeps the pre-signal heuristic. When kern reports the cap did
1594
- // not bind (2), a SIGKILL cannot be attributed to the box's cgroup - keep the honest `killed`.
1595
- if (this.memoryMb !== null && capSignal !== 2)
1789
+ if ((rc === EXIT_SIGKILL && killedBy(SIG_KILL)) || signal === "SIGKILL") {
1790
+ // Only kern's own OOM sentence buys the `oom` label (`kernReportedOom`, the one definition shared
1791
+ // with the resident-kernel and pool death paths).
1792
+ //
1793
+ // WHAT THIS REPLACED, because the replaced version read as sound: a SIGKILL of a memory-capped box
1794
+ // was called the cgroup OOM-killer, which is what a breached memory.max does (kern sets
1795
+ // memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
1796
+ // 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
1797
+ // kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
1798
+ if (oomSignal === 1 || kernReportedOom(stderr))
1596
1799
  return sandboxFault(
1597
1800
  "oom",
1598
1801
  "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
1599
1802
  );
1803
+ // `capSignal` (kern's UNFORGEABLE enforcement byte: 1 = enforced, 2 = requested but NOT enforced
1804
+ // here, 0 = undetermined) no longer decides the TYPE - a SIGKILL on a capped box is not evidence of
1805
+ // an OOM, whatever the byte says - and a 2 still earns its own sentence, because "your cap was not
1806
+ // in force here" is the one thing the caller cannot find out for itself.
1600
1807
  if (capSignal === 2)
1601
1808
  return sandboxFault(
1602
1809
  "killed",
1603
- "the box was SIGKILLed, but its memory cap was not enforced here (no cgroup delegation), so it is not attributed to a cgroup OOM",
1810
+ "the box was SIGKILLed, and its memory cap was not enforced here (no cgroup delegation), so no memory limit was in force to attribute it to",
1811
+ );
1812
+ if (this.memoryMb !== null)
1813
+ return sandboxFault(
1814
+ "killed",
1815
+ "the box was SIGKILLed and the kernel reported no OOM against its memory cap: this is an external kill (`kern stop`, a signal, or the host's own OOM killer), not the box exceeding its own memory",
1604
1816
  );
1605
1817
  return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
1606
1818
  }
1607
- if (rc === EXIT_SIGTERM || signal === "SIGTERM")
1819
+ if ((rc === EXIT_SIGTERM && killedBy(SIG_TERM)) || signal === "SIGTERM")
1608
1820
  return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
1609
1821
  // Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
1610
1822
  // PARENT before the box runs). kern's box-not-started paths BOTH exit 125 AND print a `kern:` marker,
@@ -2164,10 +2376,12 @@ class Kernel {
2164
2376
  this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
2165
2377
  this._stderr = Buffer.alloc(0);
2166
2378
  this._dead = false;
2167
- // kern's memory-cap enforcement byte (2nd byte of KERN_STARTED_FD). For a RESIDENT box kern writes
2168
- // it only at box teardown (a cell kills the kernel), so it arrives ~concurrent with the death we
2169
- // detect on stdout; read once, bounded, on death (`_readCapSignal`). 0 = undetermined / old kern.
2170
- this._capSignal = 0;
2379
+ // kern's KERN_STARTED_FD bytes for a RESIDENT box: the enforcement byte (2nd) and the OOM-outcome
2380
+ // byte (3rd). kern writes them only at box teardown (a cell kills the kernel), so they arrive
2381
+ // ~concurrent with the death we detect on stdout; read once, bounded, on death (`_readCapSignal`).
2382
+ // Kept as the raw buffer because the bytes arrive in ONE atomic write and a stream is free to deliver
2383
+ // it in pieces. Absent bytes read as 0 = undetermined / old kern.
2384
+ this._startedSig = Buffer.alloc(0);
2171
2385
  }
2172
2386
 
2173
2387
  async _open() {
@@ -2194,7 +2408,7 @@ class Kernel {
2194
2408
  });
2195
2409
  const startedCh = this._child.stdio[3];
2196
2410
  if (startedCh) {
2197
- startedCh.on("data", (b) => { if (b.length >= 2) this._capSignal = b[1]; });
2411
+ startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
2198
2412
  startedCh.on("error", () => {});
2199
2413
  }
2200
2414
  this._child.on("error", () => { this._dead = true; this._flush(null); });
@@ -2295,7 +2509,7 @@ class Kernel {
2295
2509
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
2296
2510
  if (reply === null) {
2297
2511
  const err = this._stderr.toString("utf8");
2298
- const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
2512
+ const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
2299
2513
  return this._teardownResult(kind, err.trim() || dflt, started);
2300
2514
  }
2301
2515
  return this._resultFromReply(reply, started);
@@ -2342,41 +2556,53 @@ class Kernel {
2342
2556
  });
2343
2557
  }
2344
2558
 
2345
- /** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. A kern setup marker on
2346
- * stderr means it never came up (startup_failed). Otherwise this is the runCode counterpart of the
2347
- * one-shot _classify SIGKILL branch - a kernel death has no per-cell exit code. `capSignal` is kern's
2348
- * unforgeable enforcement byte (0 = old kern / undetermined, 1 = memory cap enforced, 2 = requested
2349
- * but NOT enforced): with a memoryMb cap AND not-reported-unenforced (`!== 2`), the cgroup OOM-killer
2350
- * is the cause -> `oom`; when kern reports the cap did not bind (2), the kill is not attributable to
2351
- * the box's cgroup -> `killed`; uncapped is also `killed`. */
2352
- _kernelDeathFault(err, capSignal = 0) {
2559
+ /** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. The runCode counterpart of
2560
+ * the one-shot _classify SIGKILL branch: a kernel death has no per-cell exit code, so the whole verdict
2561
+ * is made here from what kern wrote.
2562
+ *
2563
+ * ORDER, and it was measured wrong before: kern's OOM sentence is asked about FIRST, because it is
2564
+ * `kern:`-prefixed and so was also matching the box-did-not-start heuristic below. A real OOM on a
2565
+ * resident kernel therefore came back `startup_failed`, which `_teardownResult` THROWS - so the
2566
+ * flagship path could not produce an `oom` fault at all, while an external `kern stop` DID produce one
2567
+ * from the memoryMb inference. Two defects pointing opposite ways.
2568
+ *
2569
+ * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
2570
+ * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
2571
+ * "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"];
2353
2574
  if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
2354
- if (this._sbx.memoryMb !== null && capSignal !== 2)
2355
- return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
2356
2575
  if (capSignal === 2)
2357
2576
  return [
2358
2577
  "killed",
2359
- "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",
2578
+ "the kernel box was killed, and its memory cap was not enforced here (no cgroup delegation), so no memory limit was in force to attribute it to",
2579
+ ];
2580
+ if (this._sbx.memoryMb !== null)
2581
+ return [
2582
+ "killed",
2583
+ "the kernel box was killed and the kernel reported no OOM against its memory cap: an external kill (`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory",
2360
2584
  ];
2361
2585
  return ["killed", "the kernel box exited"];
2362
2586
  }
2363
2587
 
2364
- /** kern's memory-cap enforcement byte for the resident box, read ONCE on kernel death. kern writes
2365
- * the two-byte KERN_STARTED_FD signal only at box teardown (a resident box exits when a cell kills it),
2366
- * ~concurrent with the death detected on stdout. The fd-3 `data` handler in `_open` records the byte
2367
- * as it arrives; this awaits a BOUNDED window (the fd's own `end`, or 1 s) so the read is deterministic
2368
- * rather than a race, then returns the byte (0 = EOF / old kern / not yet -> the memoryMb heuristic). */
2588
+ /** kern's enforcement and OOM-outcome bytes for the resident box, read ONCE on kernel death, as
2589
+ * `[capSignal, oomSignal]`. kern writes the KERN_STARTED_FD signal only at box teardown (a resident box
2590
+ * exits when a cell kills it), ~concurrent with the death detected on stdout. The fd-3 `data` handler in
2591
+ * `_open` accumulates the bytes as they arrive; this awaits a BOUNDED window (the fd's own `end`, or
2592
+ * 1 s) so the read is deterministic rather than a race. `[0, 0]` on EOF / an old kern / not yet, which
2593
+ * falls back to kern's stderr sentence. */
2369
2594
  async _readCapSignal() {
2370
2595
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2371
- if (!ch) return 0;
2372
- if (this._capSignal === 0 && !ch.destroyed) {
2596
+ if (!ch) return [0, 0];
2597
+ if (this._startedSig.length < 3 && !ch.destroyed) {
2373
2598
  await new Promise((res) => {
2374
2599
  const t = setTimeout(res, 1000);
2375
2600
  ch.once("end", () => { clearTimeout(t); res(); });
2376
2601
  ch.once("error", () => { clearTimeout(t); res(); });
2377
2602
  });
2378
2603
  }
2379
- return this._capSignal;
2604
+ const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2605
+ return [capSignal, oomSignal];
2380
2606
  }
2381
2607
 
2382
2608
  _teardownResult(type, message, started) {
@@ -2512,7 +2738,7 @@ class WarmBox {
2512
2738
  this._born = Date.now();
2513
2739
  this._spent = false;
2514
2740
  this._rc = null;
2515
- this._capSignal = 0;
2741
+ this._startedSig = Buffer.alloc(0); // KERN_STARTED_FD bytes: [started, cap enforcement, OOM outcome]
2516
2742
  this._stderr = Buffer.alloc(0);
2517
2743
  this._chunks = [];
2518
2744
  this._total = 0;
@@ -2554,7 +2780,7 @@ class WarmBox {
2554
2780
  }
2555
2781
  const startedCh = this._child.stdio[3];
2556
2782
  if (startedCh) {
2557
- startedCh.on("data", (b) => { if (b.length >= 2) this._capSignal = b[1]; });
2783
+ startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
2558
2784
  startedCh.on("error", () => {});
2559
2785
  }
2560
2786
  this._child.on("error", () => { this._dead = true; this._flush(null); });
@@ -2739,18 +2965,26 @@ class WarmBox {
2739
2965
  fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
2740
2966
  });
2741
2967
  }
2742
- const capSignal = this._capSignal;
2968
+ const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2743
2969
  this.retire();
2744
- if (looksLikeStartupFailure(err)) throw new SandboxError(err.trim() || "the box failed to start");
2745
2970
  let type = "killed";
2746
2971
  let dflt = "the box exited before the code finished";
2747
- if (this._sbx.memoryMb !== null && this._sbx.memoryMb !== undefined && capSignal !== 2) {
2972
+ // Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
2973
+ // the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
2974
+ // what keeps a pool box's OOM from being thrown as a box that never came up.
2975
+ if (oomSignal === 1 || kernReportedOom(err)) {
2748
2976
  type = "oom";
2749
- dflt = "the box was OOM-killed (it exceeded its memory cap)";
2977
+ dflt = "the box exceeded its memory cap and was OOM-killed";
2978
+ } else if (looksLikeStartupFailure(err)) {
2979
+ throw new SandboxError(err.trim() || "the box failed to start");
2750
2980
  } else if (capSignal === 2) {
2751
2981
  dflt =
2752
- "the box was SIGKILLed, but its memory cap was not enforced here (no cgroup delegation), " +
2753
- "so it is not attributed to a cgroup OOM";
2982
+ "the box was killed, and its memory cap was not enforced here (no cgroup delegation), " +
2983
+ "so no memory limit was in force to attribute it to";
2984
+ } else if (this._sbx.memoryMb !== null && this._sbx.memoryMb !== undefined) {
2985
+ dflt =
2986
+ "the box was killed and the kernel reported no OOM against its memory cap: an external kill " +
2987
+ "(`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory";
2754
2988
  }
2755
2989
  return this._result("", "", this._exitCode(), started, before, {
2756
2990
  fault: { type, message: err.trim() || dflt },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.43",
3
+ "version": "0.2.0",
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",