kern-sandbox 0.2.1 → 0.2.3

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 +78 -13
  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.1";
39
+ const VERSION = "0.2.3";
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
@@ -421,6 +421,12 @@ const EXIT_SIGTERM = 143; // SIGTERM: kern's --timeout backstop reaping the box
421
421
  const SIG_KILL = 9;
422
422
  const SIG_TERM = 15;
423
423
  const SIG_SYS = 31;
424
+ // The fatal signals that mean THE CODE went wrong, not that the sandbox acted. NAMED, not "everything
425
+ // else": an unknown signal stays an honest `killed`. SIGKILL and SIGTERM are absent (the kill and the
426
+ // reap have their own branches) and SIGSYS is absent because it IS the sandbox acting.
427
+ // SIGILL 4, SIGABRT 6, SIGBUS 7, SIGFPE 8, SIGSEGV 11. Mirrors `_CRASH_SIGNALS`.
428
+ const CRASH_SIGNALS = new Set([4, 6, 7, 8, 11]);
429
+ const SIGNAL_NAMES = { 4: "SIGILL", 6: "SIGABRT", 7: "SIGBUS", 8: "SIGFPE", 11: "SIGSEGV" };
424
430
 
425
431
  // Per-call kwargs that DEFAULT to the Sandbox value: UNSET means "inherit the constructor's", whereas
426
432
  // an explicit `null` means "disable" (used for onStdout/onStderr overrides).
@@ -980,7 +986,9 @@ const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
980
986
  * kern's own OOM sentence to stderr and was then stopped from outside came back `fault=oom` while the
981
987
  * third byte said 0 - the inverted verdict the byte exists to close, re-opened by the sandboxed code in
982
988
  * 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`. */
989
+ * teardown, so a new binary also arrives with no byte. The remaining bound is measured: against v0.9.32
990
+ * a cell that writes the sentence and then chooses `exit 137` reports `oom`, and without the sentence
991
+ * `killed`. Mirrors `_oom_verdict`. */
984
992
  function oomVerdict(oomSignal, stderr, kernWrotePayload) {
985
993
  if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
986
994
  if (!kernWrotePayload) return false;
@@ -1469,6 +1477,16 @@ class Sandbox {
1469
1477
  * that will never exist would both litter the workspace and collide with itself. A dry argv is for
1470
1478
  * COMPARING, never for running. */
1471
1479
  _baseArgv(name, { network, timeoutS, isSetup = false, dry = false }) {
1480
+ // IDENTITY IS RE-ASSERTED PER BOX, not once per Sandbox, and an external reviewer is the reason. He
1481
+ // overwrote the verified binary IN PLACE with `/bin/true` while a Sandbox was open: the next call
1482
+ // correctly refused to call an empty run a success, and the message it refused with quoted the
1483
+ // version from the FIRST verification - stating that a file which now prints `true (GNU coreutils)
1484
+ // 9.4` had "reported 'kern v0.9.32-48-gb578943'". The verdict was right and the sentence was false.
1485
+ // Re-verifying here rather than repairing the sentence: the binary about to run was no longer the
1486
+ // binary that was checked. The memo is keyed on (realpath, dev, ino, size, mtimeMs), so an unchanged
1487
+ // file costs one stat and a map lookup, and a changed one pays a `--version` and is refused by name.
1488
+ // Mirrors the same call in `_base_argv`.
1489
+ if (!dry) verifyIsKern(this._kern);
1472
1490
  const argv = [
1473
1491
  this._kern, "box", name, "--image", this.image, "--ro",
1474
1492
  "-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
@@ -2532,8 +2550,8 @@ class Kernel {
2532
2550
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
2533
2551
  if (reply === null) {
2534
2552
  const err = this._stderr.toString("utf8");
2535
- const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
2536
- return this._teardownResult(kind, err.trim() || dflt, started);
2553
+ const [kind, dflt, rc] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
2554
+ return this._teardownResult(kind, err.trim() || dflt, started, rc);
2537
2555
  }
2538
2556
  return this._resultFromReply(reply, started);
2539
2557
  }
@@ -2592,20 +2610,54 @@ class Kernel {
2592
2610
  * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
2593
2611
  * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
2594
2612
  * "your cap was not in force here" is the one thing the caller cannot find out for itself. */
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"];
2597
- if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
2613
+ _kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false, workloadSignal = null) {
2614
+ // THE EXIT CODE COMES FROM THE FOURTH BYTE, so both paths report one event the same way: this used to
2615
+ // be a flat -1 while the one-shot path said 137 for a kill, 159 for a blocked escape, 139 for a
2616
+ // segfault. -1 stays for the cases where no signal is known. Mirrors `_kernel_death_fault`.
2617
+ const rc = workloadSignal !== null && workloadSignal !== undefined && workloadSignal !== 0
2618
+ ? 128 + workloadSignal
2619
+ : -1;
2620
+ if (oomVerdict(oomSignal, err, kernWrotePayload)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed", rc];
2621
+ // A BLOCKED ESCAPE, BEFORE ANY STDERR HEURISTIC, and this path could not say it at all. MEASURED
2622
+ // through the MCP server, which is the path a Cursor or Claude Desktop user actually runs: a cell
2623
+ // calling a blocked syscall came back `killed` with the message "an external kill". kern's seccomp
2624
+ // filter had killed the box and the caller was told somebody stopped it. The one-shot path answers
2625
+ // this from the exit code (159); a resident kernel has no per-cell exit code, so it needs the fourth
2626
+ // byte, which was arriving unused. Before `looksLikeStartupFailure` because that heuristic matches
2627
+ // text the workload can print, and a cell must not be able to hide a blocked escape behind it. The
2628
+ // signal must have killed the box's PID 1, and a pidns init does not receive an unhandled fatal
2629
+ // signal from inside, so a cell cannot forge it. Mirrors `_kernel_death_fault`.
2630
+ if (workloadSignal === SIG_SYS)
2631
+ return [
2632
+ "escape_blocked",
2633
+ "the kernel box was killed by SIGSYS: kern's seccomp filter refused a syscall the code attempted, which is a blocked escape and not a kill from outside",
2634
+ rc,
2635
+ ];
2636
+ // A CRASH IS NOT A SANDBOX FAULT, and this path called it `killed` with that same false sentence
2637
+ // about an external kill. MEASURED: a segfaulting cell IS the box's PID 1, so the box dies, and the
2638
+ // one-shot path reports the identical event as `fault=null, exitCode=139`. Two paths disagreeing about
2639
+ // one event costs a loop: an agent reading `killed` retries the sandbox instead of fixing its code.
2640
+ // The lost session state is already reported, by the next call throwing "kernel is dead".
2641
+ if (CRASH_SIGNALS.has(workloadSignal))
2642
+ return [
2643
+ null,
2644
+ `the code crashed: the cell died on signal ${workloadSignal} (${SIGNAL_NAMES[workloadSignal]}), which took the kernel box with it because the interpreter is its PID 1. The sandbox did not act; the next call reopens a kernel`,
2645
+ rc,
2646
+ ];
2647
+ if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start", rc];
2598
2648
  if (capSignal === 2)
2599
2649
  return [
2600
2650
  "killed",
2601
2651
  "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",
2652
+ rc,
2602
2653
  ];
2603
2654
  if (this._sbx.memoryMb !== null)
2604
2655
  return [
2605
2656
  "killed",
2606
2657
  "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",
2658
+ rc,
2607
2659
  ];
2608
- return ["killed", "the kernel box exited"];
2660
+ return ["killed", "the kernel box exited", rc];
2609
2661
  }
2610
2662
 
2611
2663
  /** kern's enforcement and OOM-outcome bytes for the resident box, read ONCE on kernel death, as
@@ -2616,7 +2668,7 @@ class Kernel {
2616
2668
  * falls back to kern's stderr sentence. */
2617
2669
  async _readCapSignal() {
2618
2670
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2619
- if (!ch) return [0, null, false];
2671
+ if (!ch) return [0, null, false, null];
2620
2672
  if (this._startedSig.length < 3 && !ch.destroyed) {
2621
2673
  await new Promise((res) => {
2622
2674
  const t = setTimeout(res, 1000);
@@ -2624,19 +2676,32 @@ class Kernel {
2624
2676
  ch.once("error", () => { clearTimeout(t); res(); });
2625
2677
  });
2626
2678
  }
2627
- const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2628
- return [capSignal, oomSignal, boxStarted];
2679
+ const { boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(this._startedSig);
2680
+ return [capSignal, oomSignal, boxStarted, workloadSignal];
2629
2681
  }
2630
2682
 
2631
- _teardownResult(type, message, started) {
2683
+ _teardownResult(type, message, started, exitCode = -1) {
2632
2684
  this._kill();
2633
2685
  // Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
2634
2686
  // does not return a hollow result. timeout/killed stay as data on the returned result.
2635
2687
  if (type === "startup_failed") throw new SandboxError(message || "the box failed to start");
2688
+ // `type === null` is a real answer, not a missing one: the code CRASHED and the sandbox did not act,
2689
+ // which is what the one-shot path reports for the same event. The message still travels on stderr.
2690
+ if (type === null)
2691
+ return new ExecutionResult({
2692
+ stdout: "",
2693
+ stderr: message,
2694
+ exitCode,
2695
+ durationMs: Date.now() - started,
2696
+ fault: null,
2697
+ files: [],
2698
+ truncated: false,
2699
+ results: [],
2700
+ });
2636
2701
  return new ExecutionResult({
2637
2702
  stdout: "",
2638
2703
  stderr: "",
2639
- exitCode: -1,
2704
+ exitCode,
2640
2705
  durationMs: Date.now() - started,
2641
2706
  fault: sandboxFault(type, message),
2642
2707
  files: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",