kern-sandbox 0.2.0 → 0.2.2
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 +51 -16
- 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.2";
|
|
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
|
|
@@ -962,15 +962,39 @@ const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
|
|
|
962
962
|
* byte 2 = the OOM outcome: 1 iff the kernel's OOM killer fired against this box's own cgroup
|
|
963
963
|
* byte 3 = the signal that terminated the workload, 0 if it exited on its own
|
|
964
964
|
*
|
|
965
|
-
* A SHORT buffer is an OLDER kern, not a malformed one
|
|
966
|
-
*
|
|
967
|
-
*
|
|
965
|
+
* A SHORT buffer is an OLDER kern, not a malformed one, and the two OUTCOME bytes read `null` when
|
|
966
|
+
* absent rather than 0: for them "kern did not say" and "kern said no" are different facts and a caller
|
|
967
|
+
* acts differently on each. The enforcement byte keeps 0, which already spells "undetermined".
|
|
968
|
+
*
|
|
969
|
+
* THE OOM BYTE LEARNED THIS THE EXPENSIVE WAY: it returned 0 for both, so an older binary could only be
|
|
970
|
+
* supported by reading the stderr sentence whenever the byte was not 1, including against a binary that
|
|
971
|
+
* had just said 0. See `oomVerdict`. Mirrors `_parse_started_bytes`. */
|
|
972
|
+
/** Did the kernel's OOM killer take this box? The ONE place the byte and the sentence are combined.
|
|
973
|
+
*
|
|
974
|
+
* Three states, each naming what the SUBJECT did: the byte arrived, so it decides and the sentence is
|
|
975
|
+
* not read; no byte and kern wrote NOTHING, so kern never reached the teardown where it would have
|
|
976
|
+
* printed the sentence either and an OOM line in this stderr is the workload's own text; no byte but a
|
|
977
|
+
* payload was written, so this is a binary older than the byte reporting through its only channel.
|
|
978
|
+
*
|
|
979
|
+
* MEASURED on 2026-09-12 with the four-byte binary, when these were combined by `||`: a cell that wrote
|
|
980
|
+
* kern's own OOM sentence to stderr and was then stopped from outside came back `fault=oom` while the
|
|
981
|
+
* third byte said 0 - the inverted verdict the byte exists to close, re-opened by the sandboxed code in
|
|
982
|
+
* 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. The remaining bound is measured: against v0.9.32
|
|
984
|
+
* a cell that writes the sentence and then chooses `exit 137` reports `oom`, and without the sentence
|
|
985
|
+
* `killed`. Mirrors `_oom_verdict`. */
|
|
986
|
+
function oomVerdict(oomSignal, stderr, kernWrotePayload) {
|
|
987
|
+
if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
|
|
988
|
+
if (!kernWrotePayload) return false;
|
|
989
|
+
return kernReportedOom(stderr);
|
|
990
|
+
}
|
|
991
|
+
|
|
968
992
|
function parseStartedBytes(buf) {
|
|
969
993
|
const b = buf || Buffer.alloc(0);
|
|
970
994
|
return {
|
|
971
995
|
boxStarted: b.length >= 1 && b[0] === 1,
|
|
972
996
|
capSignal: b.length >= 2 ? b[1] : 0,
|
|
973
|
-
oomSignal: b.length >= 3 ? b[2] :
|
|
997
|
+
oomSignal: b.length >= 3 ? b[2] : null,
|
|
974
998
|
workloadSignal: b.length >= 4 ? b[3] : null,
|
|
975
999
|
};
|
|
976
1000
|
}
|
|
@@ -1447,6 +1471,16 @@ class Sandbox {
|
|
|
1447
1471
|
* that will never exist would both litter the workspace and collide with itself. A dry argv is for
|
|
1448
1472
|
* COMPARING, never for running. */
|
|
1449
1473
|
_baseArgv(name, { network, timeoutS, isSetup = false, dry = false }) {
|
|
1474
|
+
// IDENTITY IS RE-ASSERTED PER BOX, not once per Sandbox, and an external reviewer is the reason. He
|
|
1475
|
+
// overwrote the verified binary IN PLACE with `/bin/true` while a Sandbox was open: the next call
|
|
1476
|
+
// correctly refused to call an empty run a success, and the message it refused with quoted the
|
|
1477
|
+
// version from the FIRST verification - stating that a file which now prints `true (GNU coreutils)
|
|
1478
|
+
// 9.4` had "reported 'kern v0.9.32-48-gb578943'". The verdict was right and the sentence was false.
|
|
1479
|
+
// Re-verifying here rather than repairing the sentence: the binary about to run was no longer the
|
|
1480
|
+
// binary that was checked. The memo is keyed on (realpath, dev, ino, size, mtimeMs), so an unchanged
|
|
1481
|
+
// file costs one stat and a map lookup, and a changed one pays a `--version` and is refused by name.
|
|
1482
|
+
// Mirrors the same call in `_base_argv`.
|
|
1483
|
+
if (!dry) verifyIsKern(this._kern);
|
|
1450
1484
|
const argv = [
|
|
1451
1485
|
this._kern, "box", name, "--image", this.image, "--ro",
|
|
1452
1486
|
"-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
|
|
@@ -1569,7 +1603,7 @@ class Sandbox {
|
|
|
1569
1603
|
let child;
|
|
1570
1604
|
let boxStarted = false;
|
|
1571
1605
|
let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
|
|
1572
|
-
let oomSignal =
|
|
1606
|
+
let oomSignal = null; // 3rd started byte: 1 = OOM-killed, 0 = not, null = this kern does not say
|
|
1573
1607
|
let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
|
|
1574
1608
|
try {
|
|
1575
1609
|
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
@@ -1650,6 +1684,7 @@ class Sandbox {
|
|
|
1650
1684
|
const rc = toRc(code, signal);
|
|
1651
1685
|
let fault = this._classify(
|
|
1652
1686
|
rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
|
|
1687
|
+
boxStarted,
|
|
1653
1688
|
);
|
|
1654
1689
|
const execFail = execFailureBinary(stderr);
|
|
1655
1690
|
if (execFail !== null && rc !== 0) {
|
|
@@ -1749,8 +1784,8 @@ class Sandbox {
|
|
|
1749
1784
|
}
|
|
1750
1785
|
|
|
1751
1786
|
_classify(
|
|
1752
|
-
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal =
|
|
1753
|
-
workloadSignal = null,
|
|
1787
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = null, aliveState = ALIVE_UNKNOWN,
|
|
1788
|
+
workloadSignal = null, kernWrotePayload = false,
|
|
1754
1789
|
) {
|
|
1755
1790
|
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
1756
1791
|
// heuristic, because stderr is a channel the workload controls.
|
|
@@ -1795,7 +1830,7 @@ class Sandbox {
|
|
|
1795
1830
|
// memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
|
|
1796
1831
|
// 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
|
|
1797
1832
|
// kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
|
|
1798
|
-
if (oomSignal
|
|
1833
|
+
if (oomVerdict(oomSignal, stderr, kernWrotePayload))
|
|
1799
1834
|
return sandboxFault(
|
|
1800
1835
|
"oom",
|
|
1801
1836
|
"the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
|
|
@@ -2569,8 +2604,8 @@ class Kernel {
|
|
|
2569
2604
|
* `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
|
|
2570
2605
|
* requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
|
|
2571
2606
|
* "your cap was not in force here" is the one thing the caller cannot find out for itself. */
|
|
2572
|
-
_kernelDeathFault(err, capSignal = 0, oomSignal =
|
|
2573
|
-
if (oomSignal
|
|
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"];
|
|
2574
2609
|
if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
|
|
2575
2610
|
if (capSignal === 2)
|
|
2576
2611
|
return [
|
|
@@ -2593,7 +2628,7 @@ class Kernel {
|
|
|
2593
2628
|
* falls back to kern's stderr sentence. */
|
|
2594
2629
|
async _readCapSignal() {
|
|
2595
2630
|
const ch = this._child && this._child.stdio && this._child.stdio[3];
|
|
2596
|
-
if (!ch) return [0,
|
|
2631
|
+
if (!ch) return [0, null, false];
|
|
2597
2632
|
if (this._startedSig.length < 3 && !ch.destroyed) {
|
|
2598
2633
|
await new Promise((res) => {
|
|
2599
2634
|
const t = setTimeout(res, 1000);
|
|
@@ -2601,8 +2636,8 @@ class Kernel {
|
|
|
2601
2636
|
ch.once("error", () => { clearTimeout(t); res(); });
|
|
2602
2637
|
});
|
|
2603
2638
|
}
|
|
2604
|
-
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2605
|
-
return [capSignal, oomSignal];
|
|
2639
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2640
|
+
return [capSignal, oomSignal, boxStarted];
|
|
2606
2641
|
}
|
|
2607
2642
|
|
|
2608
2643
|
_teardownResult(type, message, started) {
|
|
@@ -2965,14 +3000,14 @@ class WarmBox {
|
|
|
2965
3000
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
2966
3001
|
});
|
|
2967
3002
|
}
|
|
2968
|
-
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
3003
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2969
3004
|
this.retire();
|
|
2970
3005
|
let type = "killed";
|
|
2971
3006
|
let dflt = "the box exited before the code finished";
|
|
2972
3007
|
// Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
|
|
2973
3008
|
// the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
|
|
2974
3009
|
// what keeps a pool box's OOM from being thrown as a box that never came up.
|
|
2975
|
-
if (oomSignal
|
|
3010
|
+
if (oomVerdict(oomSignal, err, boxStarted)) {
|
|
2976
3011
|
type = "oom";
|
|
2977
3012
|
dflt = "the box exceeded its memory cap and was OOM-killed";
|
|
2978
3013
|
} else if (looksLikeStartupFailure(err)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|