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.
- package/index.js +78 -13
- 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.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.
|
|
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
|
-
|
|
2597
|
-
|
|
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
|
|
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.
|
|
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",
|