kern-sandbox 0.1.42 → 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 +303 -60
  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.42";
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
  }
@@ -594,12 +656,21 @@ function findKern() {
594
656
  throw new SandboxError(
595
657
  "the `kern` binary was not found on PATH, and this is macOS: kern is Linux-only " +
596
658
  "(no namespaces, no cgroups on a Mac), so there is no macOS build to find. " +
597
- "Run inside a Linux VM (colima, Lima, OrbStack, UTM), where kern installs normally, " +
598
- "or set $KERN_BIN to a kern reachable from here. https://github.com/getkern/kern",
659
+ "Run inside a Linux VM (colima, Lima, OrbStack, UTM) and install it there with:\n" +
660
+ " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
661
+ "or set $KERN_BIN to a kern reachable from here.",
599
662
  );
663
+ // THE COMMAND, NOT A LINK. `npm install kern-sandbox` does NOT bring the binary: this package is a
664
+ // wrapper around a process it does not ship, and the moment a user meets that fact is this error.
665
+ // It used to answer with a repository URL, which asks someone one paste away from working to go
666
+ // and read a page first. The same sentence the Python binding gives, deliberately: two wrappers
667
+ // around one runtime must not disagree about how to get it, and the installer line is the one the
668
+ // project's README leads with.
600
669
  throw new SandboxError(
601
- "the `kern` binary was not found on PATH - install it " +
602
- "(https://github.com/getkern/kern) or set $KERN_BIN",
670
+ "the `kern` binary was not found on PATH. `npm install kern-sandbox` installs this wrapper, " +
671
+ "not the runtime it drives - install kern with:\n" +
672
+ " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
673
+ "or point $KERN_BIN at a kern you already have.",
603
674
  );
604
675
  }
605
676
 
@@ -875,7 +946,74 @@ function isKernDiagnostic(line) {
875
946
  return KERN_DIAGNOSTICS.some((p) => s.startsWith(p));
876
947
  }
877
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";
878
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
+ }
879
1017
 
880
1018
  function looksLikeStartupFailure(stderr) {
881
1019
  const markers = [
@@ -889,9 +1027,13 @@ function looksLikeStartupFailure(stderr) {
889
1027
  "error: oci:",
890
1028
  "error: image:",
891
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.
892
1034
  for (const line of stderr.split("\n")) {
893
1035
  const s = line.replace(/^\s+/, "");
894
- if (isKernDiagnostic(s)) continue;
1036
+ if (isKernDiagnostic(s) || kernReportedOom(s)) continue;
895
1037
  if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
896
1038
  }
897
1039
  return false;
@@ -1415,32 +1557,71 @@ class Sandbox {
1415
1557
  // OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
1416
1558
  // compatible).
1417
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";
1418
1566
 
1419
1567
  const started = process.hrtime.bigint();
1420
1568
  return new Promise((resolve, reject) => {
1421
1569
  let child;
1422
1570
  let boxStarted = false;
1423
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
1424
1574
  try {
1425
1575
  // detached: own process group, so we can signal the box + kern as a unit (killpg).
1426
1576
  // The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
1427
1577
  child = spawn(argv[0], argv.slice(1), {
1428
1578
  env: childEnv,
1429
1579
  detached: true,
1430
- stdio: ["ignore", "pipe", "pipe", "pipe"],
1580
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
1431
1581
  });
1432
1582
  } catch (e) {
1433
1583
  this._removeEnvFile(name);
1434
1584
  return reject(new SandboxError(`could not spawn the box: ${e.message}`));
1435
1585
  }
1436
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
+ }
1437
1610
  const startedCh = child.stdio[3];
1438
1611
  if (startedCh) {
1439
1612
  // Byte 0 (0x01) = the box started; stream end with no byte = never started / old kern. Byte 1
1440
- // (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);
1441
1622
  startedCh.on("data", (b) => {
1442
- if (b.length && b[0] === 1) boxStarted = true;
1443
- if (b.length >= 2) capSignal = b[1];
1623
+ sig = Buffer.concat([sig, b]);
1624
+ ({ boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(sig));
1444
1625
  });
1445
1626
  startedCh.on("error", () => {});
1446
1627
  }
@@ -1467,7 +1648,9 @@ class Sandbox {
1467
1648
  const stdout = out.buffer().toString("utf8");
1468
1649
  const stderr = err.buffer().toString("utf8");
1469
1650
  const rc = toRc(code, signal);
1470
- 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
+ );
1471
1654
  const execFail = execFailureBinary(stderr);
1472
1655
  if (execFail !== null && rc !== 0) {
1473
1656
  // BEFORE the suppression below, which would erase it: the box started, so that branch
@@ -1565,37 +1748,75 @@ class Sandbox {
1565
1748
  }
1566
1749
  }
1567
1750
 
1568
- _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
+ ) {
1569
1755
  // ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
1570
1756
  // heuristic, because stderr is a channel the workload controls.
1571
- 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
+ );
1572
1773
  return sandboxFault(
1573
1774
  "timeout",
1574
1775
  `exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
1575
1776
  );
1576
- 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")
1577
1788
  return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
1578
- if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
1579
- // A memory-capped box SIGKILLed is the cgroup OOM-killer - what a breached memory.max does (kern
1580
- // sets memory.oom.group=1, so the whole box dies at once). `capSignal` is kern's UNFORGEABLE
1581
- // enforcement byte (2nd byte of KERN_STARTED_FD, not the workload's stderr): 1 = enforced, 2 =
1582
- // requested but NOT enforced here, 0 = undetermined (old kern / no --memory). Claim `oom` when a
1583
- // --memory cap was set AND kern did not report it unenforced (capSignal !== 2): enforced (1) is a
1584
- // certain cgroup OOM, undetermined (0) keeps the pre-signal heuristic. When kern reports the cap did
1585
- // not bind (2), a SIGKILL cannot be attributed to the box's cgroup - keep the honest `killed`.
1586
- 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))
1587
1799
  return sandboxFault(
1588
1800
  "oom",
1589
1801
  "the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
1590
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.
1591
1807
  if (capSignal === 2)
1592
1808
  return sandboxFault(
1593
1809
  "killed",
1594
- "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",
1595
1816
  );
1596
1817
  return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
1597
1818
  }
1598
- if (rc === EXIT_SIGTERM || signal === "SIGTERM")
1819
+ if ((rc === EXIT_SIGTERM && killedBy(SIG_TERM)) || signal === "SIGTERM")
1599
1820
  return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
1600
1821
  // Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
1601
1822
  // PARENT before the box runs). kern's box-not-started paths BOTH exit 125 AND print a `kern:` marker,
@@ -2155,10 +2376,12 @@ class Kernel {
2155
2376
  this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
2156
2377
  this._stderr = Buffer.alloc(0);
2157
2378
  this._dead = false;
2158
- // kern's memory-cap enforcement byte (2nd byte of KERN_STARTED_FD). For a RESIDENT box kern writes
2159
- // it only at box teardown (a cell kills the kernel), so it arrives ~concurrent with the death we
2160
- // detect on stdout; read once, bounded, on death (`_readCapSignal`). 0 = undetermined / old kern.
2161
- 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);
2162
2385
  }
2163
2386
 
2164
2387
  async _open() {
@@ -2185,7 +2408,7 @@ class Kernel {
2185
2408
  });
2186
2409
  const startedCh = this._child.stdio[3];
2187
2410
  if (startedCh) {
2188
- startedCh.on("data", (b) => { if (b.length >= 2) this._capSignal = b[1]; });
2411
+ startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
2189
2412
  startedCh.on("error", () => {});
2190
2413
  }
2191
2414
  this._child.on("error", () => { this._dead = true; this._flush(null); });
@@ -2286,7 +2509,7 @@ class Kernel {
2286
2509
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
2287
2510
  if (reply === null) {
2288
2511
  const err = this._stderr.toString("utf8");
2289
- const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
2512
+ const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
2290
2513
  return this._teardownResult(kind, err.trim() || dflt, started);
2291
2514
  }
2292
2515
  return this._resultFromReply(reply, started);
@@ -2333,41 +2556,53 @@ class Kernel {
2333
2556
  });
2334
2557
  }
2335
2558
 
2336
- /** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. A kern setup marker on
2337
- * stderr means it never came up (startup_failed). Otherwise this is the runCode counterpart of the
2338
- * one-shot _classify SIGKILL branch - a kernel death has no per-cell exit code. `capSignal` is kern's
2339
- * unforgeable enforcement byte (0 = old kern / undetermined, 1 = memory cap enforced, 2 = requested
2340
- * but NOT enforced): with a memoryMb cap AND not-reported-unenforced (`!== 2`), the cgroup OOM-killer
2341
- * is the cause -> `oom`; when kern reports the cap did not bind (2), the kill is not attributable to
2342
- * the box's cgroup -> `killed`; uncapped is also `killed`. */
2343
- _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"];
2344
2574
  if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
2345
- if (this._sbx.memoryMb !== null && capSignal !== 2)
2346
- return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
2347
2575
  if (capSignal === 2)
2348
2576
  return [
2349
2577
  "killed",
2350
- "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",
2351
2584
  ];
2352
2585
  return ["killed", "the kernel box exited"];
2353
2586
  }
2354
2587
 
2355
- /** kern's memory-cap enforcement byte for the resident box, read ONCE on kernel death. kern writes
2356
- * the two-byte KERN_STARTED_FD signal only at box teardown (a resident box exits when a cell kills it),
2357
- * ~concurrent with the death detected on stdout. The fd-3 `data` handler in `_open` records the byte
2358
- * as it arrives; this awaits a BOUNDED window (the fd's own `end`, or 1 s) so the read is deterministic
2359
- * 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. */
2360
2594
  async _readCapSignal() {
2361
2595
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2362
- if (!ch) return 0;
2363
- if (this._capSignal === 0 && !ch.destroyed) {
2596
+ if (!ch) return [0, 0];
2597
+ if (this._startedSig.length < 3 && !ch.destroyed) {
2364
2598
  await new Promise((res) => {
2365
2599
  const t = setTimeout(res, 1000);
2366
2600
  ch.once("end", () => { clearTimeout(t); res(); });
2367
2601
  ch.once("error", () => { clearTimeout(t); res(); });
2368
2602
  });
2369
2603
  }
2370
- return this._capSignal;
2604
+ const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2605
+ return [capSignal, oomSignal];
2371
2606
  }
2372
2607
 
2373
2608
  _teardownResult(type, message, started) {
@@ -2503,7 +2738,7 @@ class WarmBox {
2503
2738
  this._born = Date.now();
2504
2739
  this._spent = false;
2505
2740
  this._rc = null;
2506
- this._capSignal = 0;
2741
+ this._startedSig = Buffer.alloc(0); // KERN_STARTED_FD bytes: [started, cap enforcement, OOM outcome]
2507
2742
  this._stderr = Buffer.alloc(0);
2508
2743
  this._chunks = [];
2509
2744
  this._total = 0;
@@ -2545,7 +2780,7 @@ class WarmBox {
2545
2780
  }
2546
2781
  const startedCh = this._child.stdio[3];
2547
2782
  if (startedCh) {
2548
- startedCh.on("data", (b) => { if (b.length >= 2) this._capSignal = b[1]; });
2783
+ startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
2549
2784
  startedCh.on("error", () => {});
2550
2785
  }
2551
2786
  this._child.on("error", () => { this._dead = true; this._flush(null); });
@@ -2730,18 +2965,26 @@ class WarmBox {
2730
2965
  fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
2731
2966
  });
2732
2967
  }
2733
- const capSignal = this._capSignal;
2968
+ const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2734
2969
  this.retire();
2735
- if (looksLikeStartupFailure(err)) throw new SandboxError(err.trim() || "the box failed to start");
2736
2970
  let type = "killed";
2737
2971
  let dflt = "the box exited before the code finished";
2738
- 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)) {
2739
2976
  type = "oom";
2740
- 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");
2741
2980
  } else if (capSignal === 2) {
2742
2981
  dflt =
2743
- "the box was SIGKILLed, but its memory cap was not enforced here (no cgroup delegation), " +
2744
- "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";
2745
2988
  }
2746
2989
  return this._result("", "", this._exitCode(), started, before, {
2747
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.42",
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",