kern-sandbox 0.2.2 → 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 +65 -12
  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.2";
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).
@@ -2544,8 +2550,8 @@ class Kernel {
2544
2550
  return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
2545
2551
  if (reply === null) {
2546
2552
  const err = this._stderr.toString("utf8");
2547
- const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
2548
- 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);
2549
2555
  }
2550
2556
  return this._resultFromReply(reply, started);
2551
2557
  }
@@ -2604,20 +2610,54 @@ class Kernel {
2604
2610
  * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
2605
2611
  * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
2606
2612
  * "your cap was not in force here" is the one thing the caller cannot find out for itself. */
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"];
2609
- 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];
2610
2648
  if (capSignal === 2)
2611
2649
  return [
2612
2650
  "killed",
2613
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,
2614
2653
  ];
2615
2654
  if (this._sbx.memoryMb !== null)
2616
2655
  return [
2617
2656
  "killed",
2618
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,
2619
2659
  ];
2620
- return ["killed", "the kernel box exited"];
2660
+ return ["killed", "the kernel box exited", rc];
2621
2661
  }
2622
2662
 
2623
2663
  /** kern's enforcement and OOM-outcome bytes for the resident box, read ONCE on kernel death, as
@@ -2628,7 +2668,7 @@ class Kernel {
2628
2668
  * falls back to kern's stderr sentence. */
2629
2669
  async _readCapSignal() {
2630
2670
  const ch = this._child && this._child.stdio && this._child.stdio[3];
2631
- if (!ch) return [0, null, false];
2671
+ if (!ch) return [0, null, false, null];
2632
2672
  if (this._startedSig.length < 3 && !ch.destroyed) {
2633
2673
  await new Promise((res) => {
2634
2674
  const t = setTimeout(res, 1000);
@@ -2636,19 +2676,32 @@ class Kernel {
2636
2676
  ch.once("error", () => { clearTimeout(t); res(); });
2637
2677
  });
2638
2678
  }
2639
- const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
2640
- return [capSignal, oomSignal, boxStarted];
2679
+ const { boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(this._startedSig);
2680
+ return [capSignal, oomSignal, boxStarted, workloadSignal];
2641
2681
  }
2642
2682
 
2643
- _teardownResult(type, message, started) {
2683
+ _teardownResult(type, message, started, exitCode = -1) {
2644
2684
  this._kill();
2645
2685
  // Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
2646
2686
  // does not return a hollow result. timeout/killed stay as data on the returned result.
2647
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
+ });
2648
2701
  return new ExecutionResult({
2649
2702
  stdout: "",
2650
2703
  stderr: "",
2651
- exitCode: -1,
2704
+ exitCode,
2652
2705
  durationMs: Date.now() - started,
2653
2706
  fault: sandboxFault(type, message),
2654
2707
  files: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.2",
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",