kern-sandbox 0.2.2 → 0.2.4
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.
- package/index.js +65 -12
- 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.
|
|
39
|
+
const VERSION = "0.2.4";
|
|
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
|
-
|
|
2609
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|