kern-sandbox 0.1.43 → 0.2.0
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/README.md +3 -3
- package/index.js +290 -56
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -133,10 +133,10 @@ LangChain tool and the MCP server already use it.
|
|
|
133
133
|
|---|---|
|
|
134
134
|
| `timeout` | the call exceeded `timeoutS`; the binding killed the box |
|
|
135
135
|
| `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
|
|
136
|
-
| `oom` | the
|
|
137
|
-
| `killed` |
|
|
138
|
-
|
|
136
|
+
| `oom` | kern reported that the kernel's OOM killer took the box against its own memory cap: a breached `memory.max` takes the whole box, since kern sets `memory.oom.group=1`. Reported on a channel the code in the box cannot write (a third byte on kern's own descriptor), so it is an observation of the kernel's counter rather than a guess from the exit code |
|
|
137
|
+
| `killed` | the box was SIGKILLed with **no** OOM reported against its cap: an external kill (`kern stop`, a signal, the host running out of memory), or a cap that did not bind here (no cgroup delegation, which the message names). A `memoryMb` cap being set is not, by itself, evidence that memory is what killed the box |
|
|
139
138
|
| `exec_failed` | the box started but the command did not exist inside it. `runCode(code, {language:"node"})` on an image with no `node` is the ordinary way to reach it; the message names the binary AND the image, because the remedy is a different `language` or a different `image`. The `language` enum is a convenience, not a promise about the image: the default `python:3.12-slim` carries `python` and `bash`. A shell's own `command not found` inside your script stays an ordinary non-zero exit |
|
|
139
|
+
| `startup_failed` | returned as data, not thrown, in one case: your `timeoutS` fired while kern was still BUILDING the box, so the code never ran. kern reports on a separate descriptor whether it reached your workload, which is what tells this from a slow cell. A host path that blocks does it: a bind source on a dead NFS export, a FUSE mount whose daemon is gone. A longer timeout does not help |
|
|
140
140
|
|
|
141
141
|
**An enforced `pids` cap produces no fault, and that is deliberate.** When `pids` binds, the refused
|
|
142
142
|
`fork` returns `EAGAIN`. Code that catches it exits 0, so the call reports `fault: null, success:
|
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.
|
|
39
|
+
const VERSION = "0.2.0";
|
|
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
|
|
@@ -415,6 +415,12 @@ while True:
|
|
|
415
415
|
const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
|
|
416
416
|
const EXIT_SIGSYS = 159; // SIGSYS: a seccomp-denied syscall = a blocked escape attempt
|
|
417
417
|
const EXIT_SIGTERM = 143; // SIGTERM: kern's --timeout backstop reaping the box
|
|
418
|
+
// The signal NUMBERS behind those codes, for kern's 4th started-byte. Spelled out rather than derived
|
|
419
|
+
// from the code, because `128 + N` is the convention being checked and deriving N from it would make the
|
|
420
|
+
// check circular.
|
|
421
|
+
const SIG_KILL = 9;
|
|
422
|
+
const SIG_TERM = 15;
|
|
423
|
+
const SIG_SYS = 31;
|
|
418
424
|
|
|
419
425
|
// Per-call kwargs that DEFAULT to the Sandbox value: UNSET means "inherit the constructor's", whereas
|
|
420
426
|
// an explicit `null` means "disable" (used for onStdout/onStderr overrides).
|
|
@@ -562,7 +568,59 @@ function sandboxFault(type, message) {
|
|
|
562
568
|
return { type, message };
|
|
563
569
|
}
|
|
564
570
|
|
|
565
|
-
/**
|
|
571
|
+
/** Binaries already identified as kern, keyed by identity and not by path: a `kern` REPLACED between two
|
|
572
|
+
* calls is a different program and gets checked again. */
|
|
573
|
+
const VERIFIED_KERN = new Set();
|
|
574
|
+
|
|
575
|
+
/** Refuse a binary that does not IDENTIFY ITSELF as kern. Throws `SandboxError` if it does not.
|
|
576
|
+
*
|
|
577
|
+
* MEASURED, and found by an external reviewer running the positive control this project wrote for him:
|
|
578
|
+
* with `KERN_BIN=/bin/true` a call returned `success: true, exitCode: 0, fault: null` and an empty
|
|
579
|
+
* stdout. The code never ran and the caller was told it had. Any `kern` earlier in `PATH` that is not
|
|
580
|
+
* kern does this: a leftover wrapper, a shim, a no-op. An agent loop reads `success` and every
|
|
581
|
+
* conclusion after that is about a program that never executed.
|
|
582
|
+
*
|
|
583
|
+
* POSITIVE IDENTIFICATION, not inference from a missing signal: a kern old enough to predate
|
|
584
|
+
* `KERN_STARTED_FD` writes no bytes either, and refusing it would punish an old binary rather than a
|
|
585
|
+
* fake one. `kern --version` prints `kern <version>`, a prefix kern's own suite asserts.
|
|
586
|
+
*
|
|
587
|
+
* Memoised per binary identity, so it costs one `--version` (measured at 0.9 ms) per distinct binary
|
|
588
|
+
* per process and nothing afterwards. Fail-closed: unrunnable, slow or unrecognised is refused, because
|
|
589
|
+
* an unverifiable runtime is the case this exists for. Mirrors `_verify_is_kern`. */
|
|
590
|
+
function verifyIsKern(bin) {
|
|
591
|
+
let key;
|
|
592
|
+
try {
|
|
593
|
+
const st = fs.statSync(bin);
|
|
594
|
+
key = [fs.realpathSync(bin), st.dev, st.ino, st.size, st.mtimeMs].join("|");
|
|
595
|
+
} catch (e) {
|
|
596
|
+
throw new SandboxError(`could not stat the kern binary at '${bin}': ${e.message}`);
|
|
597
|
+
}
|
|
598
|
+
if (VERIFIED_KERN.has(key)) return;
|
|
599
|
+
const hint =
|
|
600
|
+
"If this is not the kern you meant, set $KERN_BIN to the right path. To install kern:\n" +
|
|
601
|
+
" curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh";
|
|
602
|
+
// Generous on purpose: a loaded machine must not be told its kern is fake. `--version` writes one
|
|
603
|
+
// line, so a binary that cannot answer in ten seconds is not one to trust with a box.
|
|
604
|
+
const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 10000 });
|
|
605
|
+
if (out.error && out.error.code === "ETIMEDOUT")
|
|
606
|
+
throw new SandboxError(
|
|
607
|
+
`'${bin}' did not answer \`--version\` within 10s, so it cannot be identified as kern. ${hint}`,
|
|
608
|
+
);
|
|
609
|
+
if (out.error) throw new SandboxError(`could not run '${bin} --version': ${out.error.message}. ${hint}`);
|
|
610
|
+
const first = String(out.stdout || "").trim().split("\n")[0] || "";
|
|
611
|
+
if (out.status !== 0 || !first.startsWith("kern ")) {
|
|
612
|
+
const shown = first ? JSON.stringify(first.slice(0, 120)) : "(no output)";
|
|
613
|
+
throw new SandboxError(
|
|
614
|
+
`'${bin}' is not kern: \`${bin} --version\` exited ${out.status} and printed ${shown}, where kern ` +
|
|
615
|
+
"prints a line beginning 'kern '. Refusing to run code, because a binary that is not kern would " +
|
|
616
|
+
`return an EMPTY, SUCCESSFUL result for every call and the code would never run. ${hint}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
VERIFIED_KERN.add(key);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Locate `kern`: $KERN_BIN if set, else the first `kern` on $PATH. The result is also IDENTIFIED as
|
|
623
|
+
* kern (see `verifyIsKern`): being executable and being named `kern` are not the same as being kern. */
|
|
566
624
|
function findKern() {
|
|
567
625
|
const env = process.env.KERN_BIN;
|
|
568
626
|
if (env) {
|
|
@@ -572,6 +630,7 @@ function findKern() {
|
|
|
572
630
|
} catch {
|
|
573
631
|
throw new SandboxError(`$KERN_BIN='${env}' is not an executable file`);
|
|
574
632
|
}
|
|
633
|
+
verifyIsKern(env);
|
|
575
634
|
return env;
|
|
576
635
|
}
|
|
577
636
|
const exts = [""];
|
|
@@ -581,7 +640,10 @@ function findKern() {
|
|
|
581
640
|
const cand = path.join(d, "kern" + ext);
|
|
582
641
|
try {
|
|
583
642
|
fs.accessSync(cand, fs.constants.X_OK);
|
|
584
|
-
if (fs.statSync(cand).isFile())
|
|
643
|
+
if (fs.statSync(cand).isFile()) {
|
|
644
|
+
verifyIsKern(cand);
|
|
645
|
+
return cand;
|
|
646
|
+
}
|
|
585
647
|
} catch {
|
|
586
648
|
/* keep looking */
|
|
587
649
|
}
|
|
@@ -884,7 +946,74 @@ function isKernDiagnostic(line) {
|
|
|
884
946
|
return KERN_DIAGNOSTICS.some((p) => s.startsWith(p));
|
|
885
947
|
}
|
|
886
948
|
|
|
949
|
+
/** The sentence kern prints when it has READ the kernel's OOM counter for this box's own cgroup. A
|
|
950
|
+
* contract between two programs: if kern rewords it, this stops recognising a real OOM and starts
|
|
951
|
+
* reporting `killed` - wrong in the safe direction, still wrong. Mirrors `_KERN_OOM_MARKER`. */
|
|
952
|
+
const KERN_OOM_MARKER = "killed by the kernel's OOM killer";
|
|
887
953
|
|
|
954
|
+
/** kern's KERN_STARTED_FD payload, as `{ boxStarted, capSignal, oomSignal, workloadSignal }`.
|
|
955
|
+
*
|
|
956
|
+
* THE WIRE FORMAT IS SPELLED HERE AND NOWHERE ELSE, because it grew twice in one day (the OOM outcome,
|
|
957
|
+
* then the workload's signal) and each time a reader with its own copy of the layout was left behind.
|
|
958
|
+
* Three places in this file read those bytes; they now all read them through this.
|
|
959
|
+
*
|
|
960
|
+
* byte 0 = the box started (kern reached its `Ok` arm with a code that is not 125)
|
|
961
|
+
* byte 1 = the memory-cap enforcement signal: 0 undetermined, 1 enforced, 2 requested but not enforced
|
|
962
|
+
* byte 2 = the OOM outcome: 1 iff the kernel's OOM killer fired against this box's own cgroup
|
|
963
|
+
* byte 3 = the signal that terminated the workload, 0 if it exited on its own
|
|
964
|
+
*
|
|
965
|
+
* A SHORT buffer is an OLDER kern, not a malformed one: every absent byte reads as its "undetermined"
|
|
966
|
+
* value (0, and `null` for the signal, which must stay distinguishable from "nothing killed it").
|
|
967
|
+
* Mirrors `_parse_started_bytes`. */
|
|
968
|
+
function parseStartedBytes(buf) {
|
|
969
|
+
const b = buf || Buffer.alloc(0);
|
|
970
|
+
return {
|
|
971
|
+
boxStarted: b.length >= 1 && b[0] === 1,
|
|
972
|
+
capSignal: b.length >= 2 ? b[1] : 0,
|
|
973
|
+
oomSignal: b.length >= 3 ? b[2] : 0,
|
|
974
|
+
workloadSignal: b.length >= 4 ? b[3] : null,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** The byte kern writes to `KERN_ALIVE_FD` the moment it accepts the descriptor, before any box setup.
|
|
979
|
+
* It is what tells "the setup has not finished" from "this binary does not speak the protocol": an older
|
|
980
|
+
* kern never writes to that pipe AND never closes it, so the pipe is open and silent in both cases.
|
|
981
|
+
* Mirrors `_ALIVE_ACK`. */
|
|
982
|
+
const ALIVE_ACK = 0x41; // 'A'
|
|
983
|
+
|
|
984
|
+
/** Where kern was when the deadline fired, read off the `KERN_ALIVE_FD` pipe. Spelled once so a caller
|
|
985
|
+
* cannot invent a fourth answer. Mirrors the `_ALIVE_*` constants in the Python binding. */
|
|
986
|
+
const ALIVE_PAST_SETUP = "past-setup"; // the workload ran (EOF at execvp), or kern reported setup failed
|
|
987
|
+
const ALIVE_IN_SETUP = "in-setup"; // kern acknowledged the channel and is still BUILDING the box
|
|
988
|
+
const ALIVE_UNKNOWN = "unknown"; // nothing on the pipe: a kern that predates this channel
|
|
989
|
+
|
|
990
|
+
/** True iff KERN said the kernel's OOM killer took this box against its own memory cap.
|
|
991
|
+
*
|
|
992
|
+
* The ONE definition of "this was an OOM", used by all three death paths (the one-shot exit-code
|
|
993
|
+
* classifier, the resident kernel's death, and a pool box that died) so they cannot drift into
|
|
994
|
+
* disagreeing about the same box. It is an OBSERVATION - kern reads `memory.events` and says so -
|
|
995
|
+
* where the SDK can only infer, and the inference it replaced was MEASURED wrong in both directions:
|
|
996
|
+
* `kern stop` during a cell was reported `oom`, while a real OOM on the resident kernel was reported
|
|
997
|
+
* as a box that failed to start (and raised).
|
|
998
|
+
*
|
|
999
|
+
* Anchored on kern's `kern:` line prefix, which the real line carries (measured verbatim: `kern: the
|
|
1000
|
+
* workload was killed by the kernel's OOM killer against this box's own memory cap.`), MINUS the
|
|
1001
|
+
* benign diagnostics - `kern: note: <quoting the sentence>` is kern TALKING about an OOM, not
|
|
1002
|
+
* reporting one.
|
|
1003
|
+
*
|
|
1004
|
+
* THE FALLBACK, NOT THE AUTHORITY. Against a kern that writes the 3rd KERN_STARTED_FD byte the verdict
|
|
1005
|
+
* comes from there instead, on a pipe the workload never holds. This covers an older binary, and it is
|
|
1006
|
+
* forgeable in exactly one direction: a workload that writes the whole prefixed sentence itself turns its
|
|
1007
|
+
* own `killed` into `oom`. Both are sandbox faults, and timeout / blocked-escape are decided by exit code
|
|
1008
|
+
* before any text is read, so the worst case is a caller misleading itself about its own kill.
|
|
1009
|
+
* Mirrors `_kern_reported_oom`. */
|
|
1010
|
+
function kernReportedOom(stderr) {
|
|
1011
|
+
for (const line of String(stderr || "").split("\n")) {
|
|
1012
|
+
const s = line.replace(/^\s+/, "");
|
|
1013
|
+
if (s.startsWith("kern:") && !isKernDiagnostic(s) && s.includes(KERN_OOM_MARKER)) return true;
|
|
1014
|
+
}
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
888
1017
|
|
|
889
1018
|
function looksLikeStartupFailure(stderr) {
|
|
890
1019
|
const markers = [
|
|
@@ -898,9 +1027,13 @@ function looksLikeStartupFailure(stderr) {
|
|
|
898
1027
|
"error: oci:",
|
|
899
1028
|
"error: image:",
|
|
900
1029
|
];
|
|
1030
|
+
// The OOM sentence is skipped for a sharper reason than the benign notes: it is a report about a box
|
|
1031
|
+
// that RAN, and it is `kern:`-prefixed, so it used to satisfy this predicate. MEASURED, that is how a
|
|
1032
|
+
// real OOM on a resident kernel came back as `startup_failed` and was THROWN instead of returning an
|
|
1033
|
+
// `oom` fault.
|
|
901
1034
|
for (const line of stderr.split("\n")) {
|
|
902
1035
|
const s = line.replace(/^\s+/, "");
|
|
903
|
-
if (isKernDiagnostic(s)) continue;
|
|
1036
|
+
if (isKernDiagnostic(s) || kernReportedOom(s)) continue;
|
|
904
1037
|
if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
|
|
905
1038
|
}
|
|
906
1039
|
return false;
|
|
@@ -1424,32 +1557,71 @@ class Sandbox {
|
|
|
1424
1557
|
// OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
|
|
1425
1558
|
// compatible).
|
|
1426
1559
|
childEnv.KERN_STARTED_FD = "3";
|
|
1560
|
+
// A SECOND, LIVE channel, because the first one is post-mortem. kern writes KERN_STARTED_FD at the
|
|
1561
|
+
// box's TEARDOWN, so when OUR deadline fires we kill kern before that write and learn nothing: a
|
|
1562
|
+
// workload that was slow and a kern whose SETUP blocked are the same overrun. fd 4 carries kern's
|
|
1563
|
+
// readiness pipe: the ack byte on acceptance, EOF when the workload `execvp`s (the box child marks it
|
|
1564
|
+
// FD_CLOEXEC), one byte if setup or exec failed, and nothing at all from a kern that predates it.
|
|
1565
|
+
childEnv.KERN_ALIVE_FD = "4";
|
|
1427
1566
|
|
|
1428
1567
|
const started = process.hrtime.bigint();
|
|
1429
1568
|
return new Promise((resolve, reject) => {
|
|
1430
1569
|
let child;
|
|
1431
1570
|
let boxStarted = false;
|
|
1432
1571
|
let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
|
|
1572
|
+
let oomSignal = 0; // 3rd started byte: 1 = the kernel OOM-killed this box's own cgroup, 0 = it did not
|
|
1573
|
+
let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
|
|
1433
1574
|
try {
|
|
1434
1575
|
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
1435
1576
|
// The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
|
|
1436
1577
|
child = spawn(argv[0], argv.slice(1), {
|
|
1437
1578
|
env: childEnv,
|
|
1438
1579
|
detached: true,
|
|
1439
|
-
stdio: ["ignore", "pipe", "pipe", "pipe"],
|
|
1580
|
+
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
|
|
1440
1581
|
});
|
|
1441
1582
|
} catch (e) {
|
|
1442
1583
|
this._removeEnvFile(name);
|
|
1443
1584
|
return reject(new SandboxError(`could not spawn the box: ${e.message}`));
|
|
1444
1585
|
}
|
|
1445
1586
|
|
|
1587
|
+
// The alive channel's state, updated as the pipe speaks. Read at the deadline, BEFORE the kill:
|
|
1588
|
+
// the teardown closes every write end, so afterwards the pipe reads EOF whatever the box was doing
|
|
1589
|
+
// and the question answers itself wrongly.
|
|
1590
|
+
let aliveState = ALIVE_UNKNOWN;
|
|
1591
|
+
const aliveCh = child.stdio[4];
|
|
1592
|
+
if (aliveCh) {
|
|
1593
|
+
aliveCh.on("data", (b) => {
|
|
1594
|
+
if (aliveState === ALIVE_PAST_SETUP) return;
|
|
1595
|
+
for (const byte of b) {
|
|
1596
|
+
if (byte === ALIVE_ACK) {
|
|
1597
|
+
if (aliveState === ALIVE_UNKNOWN) aliveState = ALIVE_IN_SETUP;
|
|
1598
|
+
} else {
|
|
1599
|
+
aliveState = ALIVE_PAST_SETUP; // a byte beyond the ack: kern said setup failed
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
// EOF: the box child's FD_CLOEXEC closed it at `execvp`, so the workload ran.
|
|
1605
|
+
aliveCh.on("end", () => {
|
|
1606
|
+
aliveState = ALIVE_PAST_SETUP;
|
|
1607
|
+
});
|
|
1608
|
+
aliveCh.on("error", () => {});
|
|
1609
|
+
}
|
|
1446
1610
|
const startedCh = child.stdio[3];
|
|
1447
1611
|
if (startedCh) {
|
|
1448
1612
|
// Byte 0 (0x01) = the box started; stream end with no byte = never started / old kern. Byte 1
|
|
1449
|
-
// (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0.
|
|
1613
|
+
// (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0. Byte 2
|
|
1614
|
+
// (a NEWER kern still) = the OOM OUTCOME: 1 iff the kernel's OOM killer fired against this box's
|
|
1615
|
+
// OWN cgroup. Enforcement is not an outcome, and this byte is the only place the outcome arrives
|
|
1616
|
+
// on a channel the workload cannot write.
|
|
1617
|
+
// ACCUMULATED rather than read off one chunk: kern's write is atomic, so all four bytes arrive
|
|
1618
|
+
// together in practice, but a stream that split them would silently cost us the last one - and a
|
|
1619
|
+
// lost OOM byte reads as "no OOM", a lost signal byte as "nothing killed it", both wrong answers
|
|
1620
|
+
// arrived at invisibly.
|
|
1621
|
+
let sig = Buffer.alloc(0);
|
|
1450
1622
|
startedCh.on("data", (b) => {
|
|
1451
|
-
|
|
1452
|
-
|
|
1623
|
+
sig = Buffer.concat([sig, b]);
|
|
1624
|
+
({ boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(sig));
|
|
1453
1625
|
});
|
|
1454
1626
|
startedCh.on("error", () => {});
|
|
1455
1627
|
}
|
|
@@ -1476,7 +1648,9 @@ class Sandbox {
|
|
|
1476
1648
|
const stdout = out.buffer().toString("utf8");
|
|
1477
1649
|
const stderr = err.buffer().toString("utf8");
|
|
1478
1650
|
const rc = toRc(code, signal);
|
|
1479
|
-
let fault = this._classify(
|
|
1651
|
+
let fault = this._classify(
|
|
1652
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
|
|
1653
|
+
);
|
|
1480
1654
|
const execFail = execFailureBinary(stderr);
|
|
1481
1655
|
if (execFail !== null && rc !== 0) {
|
|
1482
1656
|
// BEFORE the suppression below, which would erase it: the box started, so that branch
|
|
@@ -1574,37 +1748,75 @@ class Sandbox {
|
|
|
1574
1748
|
}
|
|
1575
1749
|
}
|
|
1576
1750
|
|
|
1577
|
-
_classify(
|
|
1751
|
+
_classify(
|
|
1752
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = 0, aliveState = ALIVE_UNKNOWN,
|
|
1753
|
+
workloadSignal = null,
|
|
1754
|
+
) {
|
|
1578
1755
|
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
1579
1756
|
// heuristic, because stderr is a channel the workload controls.
|
|
1580
|
-
if (timedOut)
|
|
1757
|
+
if (timedOut) {
|
|
1758
|
+
// AND THE DEADLINE ALONE DOES NOT SAY WHOSE FAULT IT WAS. `ALIVE_IN_SETUP` is kern's own answer,
|
|
1759
|
+
// read off the readiness pipe while kern was still alive: the box was still being BUILT, so the
|
|
1760
|
+
// code never ran and calling this a `timeout` would tell the caller their workload was slow. That
|
|
1761
|
+
// class was measured with a FIFO volume source (404 seconds in `wait_for_partner`), and the shapes
|
|
1762
|
+
// behind it - an `lstat` on a dead NFS mount, a FUSE whose daemon is gone - are not FIFOs and
|
|
1763
|
+
// cannot be refused by type. Every other state keeps the old verdict, `ALIVE_UNKNOWN` (an older
|
|
1764
|
+
// kern) included: absence of evidence is not evidence.
|
|
1765
|
+
if (aliveState === ALIVE_IN_SETUP)
|
|
1766
|
+
return sandboxFault(
|
|
1767
|
+
"startup_failed",
|
|
1768
|
+
`the box never started: kern was still setting it up when the ${timeoutS ?? this.timeoutS}s ` +
|
|
1769
|
+
"deadline fired, so the code never ran. A host path that blocks is what does this - a bind " +
|
|
1770
|
+
"source on a dead NFS or a FUSE mount whose daemon is gone, an image layer on a stalled " +
|
|
1771
|
+
"disk - and the remedy is that path, not a longer timeout",
|
|
1772
|
+
);
|
|
1581
1773
|
return sandboxFault(
|
|
1582
1774
|
"timeout",
|
|
1583
1775
|
`exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
|
|
1584
1776
|
);
|
|
1585
|
-
|
|
1777
|
+
}
|
|
1778
|
+
// THE EXIT CODE IS THE RIGHT THING TO PROPAGATE AND THE WRONG THING TO CLASSIFY FROM: kern reports
|
|
1779
|
+
// the workload's status as `128 + N`, so a workload the kernel killed and one that called `exit(137)`
|
|
1780
|
+
// are the same number. MEASURED through the Python binding: `sys.exit(137)` came back `killed` with a
|
|
1781
|
+
// message about an external kill that never happened, and `sys.exit(159)` came back `escape_blocked`,
|
|
1782
|
+
// a security event a cell could fabricate in one line. kern's 4th started-byte carries the signal.
|
|
1783
|
+
// `null` (an older kern, or a kern our teardown killed first) keeps the old exit-code reading:
|
|
1784
|
+
// absence of evidence is not evidence. `signal === "SIG..."` is a different question, kern ITSELF
|
|
1785
|
+
// being signalled, and stays as it was.
|
|
1786
|
+
const killedBy = (n) => workloadSignal === null || workloadSignal === n;
|
|
1787
|
+
if ((rc === EXIT_SIGSYS && killedBy(SIG_SYS)) || signal === "SIGSYS")
|
|
1586
1788
|
return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
|
|
1587
|
-
if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1789
|
+
if ((rc === EXIT_SIGKILL && killedBy(SIG_KILL)) || signal === "SIGKILL") {
|
|
1790
|
+
// Only kern's own OOM sentence buys the `oom` label (`kernReportedOom`, the one definition shared
|
|
1791
|
+
// with the resident-kernel and pool death paths).
|
|
1792
|
+
//
|
|
1793
|
+
// WHAT THIS REPLACED, because the replaced version read as sound: a SIGKILL of a memory-capped box
|
|
1794
|
+
// was called the cgroup OOM-killer, which is what a breached memory.max does (kern sets
|
|
1795
|
+
// memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
|
|
1796
|
+
// 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
|
|
1797
|
+
// kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
|
|
1798
|
+
if (oomSignal === 1 || kernReportedOom(stderr))
|
|
1596
1799
|
return sandboxFault(
|
|
1597
1800
|
"oom",
|
|
1598
1801
|
"the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
|
|
1599
1802
|
);
|
|
1803
|
+
// `capSignal` (kern's UNFORGEABLE enforcement byte: 1 = enforced, 2 = requested but NOT enforced
|
|
1804
|
+
// here, 0 = undetermined) no longer decides the TYPE - a SIGKILL on a capped box is not evidence of
|
|
1805
|
+
// an OOM, whatever the byte says - and a 2 still earns its own sentence, because "your cap was not
|
|
1806
|
+
// in force here" is the one thing the caller cannot find out for itself.
|
|
1600
1807
|
if (capSignal === 2)
|
|
1601
1808
|
return sandboxFault(
|
|
1602
1809
|
"killed",
|
|
1603
|
-
"the box was SIGKILLed,
|
|
1810
|
+
"the box was SIGKILLed, and its memory cap was not enforced here (no cgroup delegation), so no memory limit was in force to attribute it to",
|
|
1811
|
+
);
|
|
1812
|
+
if (this.memoryMb !== null)
|
|
1813
|
+
return sandboxFault(
|
|
1814
|
+
"killed",
|
|
1815
|
+
"the box was SIGKILLed and the kernel reported no OOM against its memory cap: this is an external kill (`kern stop`, a signal, or the host's own OOM killer), not the box exceeding its own memory",
|
|
1604
1816
|
);
|
|
1605
1817
|
return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
|
|
1606
1818
|
}
|
|
1607
|
-
if (rc === EXIT_SIGTERM || signal === "SIGTERM")
|
|
1819
|
+
if ((rc === EXIT_SIGTERM && killedBy(SIG_TERM)) || signal === "SIGTERM")
|
|
1608
1820
|
return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
|
|
1609
1821
|
// Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
|
|
1610
1822
|
// PARENT before the box runs). kern's box-not-started paths BOTH exit 125 AND print a `kern:` marker,
|
|
@@ -2164,10 +2376,12 @@ class Kernel {
|
|
|
2164
2376
|
this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
|
|
2165
2377
|
this._stderr = Buffer.alloc(0);
|
|
2166
2378
|
this._dead = false;
|
|
2167
|
-
// kern's
|
|
2168
|
-
//
|
|
2169
|
-
// detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
2170
|
-
|
|
2379
|
+
// kern's KERN_STARTED_FD bytes for a RESIDENT box: the enforcement byte (2nd) and the OOM-outcome
|
|
2380
|
+
// byte (3rd). kern writes them only at box teardown (a cell kills the kernel), so they arrive
|
|
2381
|
+
// ~concurrent with the death we detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
2382
|
+
// Kept as the raw buffer because the bytes arrive in ONE atomic write and a stream is free to deliver
|
|
2383
|
+
// it in pieces. Absent bytes read as 0 = undetermined / old kern.
|
|
2384
|
+
this._startedSig = Buffer.alloc(0);
|
|
2171
2385
|
}
|
|
2172
2386
|
|
|
2173
2387
|
async _open() {
|
|
@@ -2194,7 +2408,7 @@ class Kernel {
|
|
|
2194
2408
|
});
|
|
2195
2409
|
const startedCh = this._child.stdio[3];
|
|
2196
2410
|
if (startedCh) {
|
|
2197
|
-
startedCh.on("data", (b) => {
|
|
2411
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2198
2412
|
startedCh.on("error", () => {});
|
|
2199
2413
|
}
|
|
2200
2414
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2295,7 +2509,7 @@ class Kernel {
|
|
|
2295
2509
|
return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
|
|
2296
2510
|
if (reply === null) {
|
|
2297
2511
|
const err = this._stderr.toString("utf8");
|
|
2298
|
-
const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
|
|
2512
|
+
const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
|
|
2299
2513
|
return this._teardownResult(kind, err.trim() || dflt, started);
|
|
2300
2514
|
}
|
|
2301
2515
|
return this._resultFromReply(reply, started);
|
|
@@ -2342,41 +2556,53 @@ class Kernel {
|
|
|
2342
2556
|
});
|
|
2343
2557
|
}
|
|
2344
2558
|
|
|
2345
|
-
/** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`.
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2350
|
-
*
|
|
2351
|
-
*
|
|
2352
|
-
|
|
2559
|
+
/** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. The runCode counterpart of
|
|
2560
|
+
* the one-shot _classify SIGKILL branch: a kernel death has no per-cell exit code, so the whole verdict
|
|
2561
|
+
* is made here from what kern wrote.
|
|
2562
|
+
*
|
|
2563
|
+
* ORDER, and it was measured wrong before: kern's OOM sentence is asked about FIRST, because it is
|
|
2564
|
+
* `kern:`-prefixed and so was also matching the box-did-not-start heuristic below. A real OOM on a
|
|
2565
|
+
* resident kernel therefore came back `startup_failed`, which `_teardownResult` THROWS - so the
|
|
2566
|
+
* flagship path could not produce an `oom` fault at all, while an external `kern stop` DID produce one
|
|
2567
|
+
* from the memoryMb inference. Two defects pointing opposite ways.
|
|
2568
|
+
*
|
|
2569
|
+
* `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
|
|
2570
|
+
* requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
|
|
2571
|
+
* "your cap was not in force here" is the one thing the caller cannot find out for itself. */
|
|
2572
|
+
_kernelDeathFault(err, capSignal = 0, oomSignal = 0) {
|
|
2573
|
+
if (oomSignal === 1 || kernReportedOom(err)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
|
|
2353
2574
|
if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
|
|
2354
|
-
if (this._sbx.memoryMb !== null && capSignal !== 2)
|
|
2355
|
-
return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
|
|
2356
2575
|
if (capSignal === 2)
|
|
2357
2576
|
return [
|
|
2358
2577
|
"killed",
|
|
2359
|
-
"the kernel box was
|
|
2578
|
+
"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",
|
|
2579
|
+
];
|
|
2580
|
+
if (this._sbx.memoryMb !== null)
|
|
2581
|
+
return [
|
|
2582
|
+
"killed",
|
|
2583
|
+
"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",
|
|
2360
2584
|
];
|
|
2361
2585
|
return ["killed", "the kernel box exited"];
|
|
2362
2586
|
}
|
|
2363
2587
|
|
|
2364
|
-
/** kern's
|
|
2365
|
-
* the
|
|
2366
|
-
* ~concurrent with the death detected on stdout. The fd-3 `data` handler in
|
|
2367
|
-
* as
|
|
2368
|
-
* rather than a race,
|
|
2588
|
+
/** kern's enforcement and OOM-outcome bytes for the resident box, read ONCE on kernel death, as
|
|
2589
|
+
* `[capSignal, oomSignal]`. kern writes the KERN_STARTED_FD signal only at box teardown (a resident box
|
|
2590
|
+
* exits when a cell kills it), ~concurrent with the death detected on stdout. The fd-3 `data` handler in
|
|
2591
|
+
* `_open` accumulates the bytes as they arrive; this awaits a BOUNDED window (the fd's own `end`, or
|
|
2592
|
+
* 1 s) so the read is deterministic rather than a race. `[0, 0]` on EOF / an old kern / not yet, which
|
|
2593
|
+
* falls back to kern's stderr sentence. */
|
|
2369
2594
|
async _readCapSignal() {
|
|
2370
2595
|
const ch = this._child && this._child.stdio && this._child.stdio[3];
|
|
2371
|
-
if (!ch) return 0;
|
|
2372
|
-
if (this.
|
|
2596
|
+
if (!ch) return [0, 0];
|
|
2597
|
+
if (this._startedSig.length < 3 && !ch.destroyed) {
|
|
2373
2598
|
await new Promise((res) => {
|
|
2374
2599
|
const t = setTimeout(res, 1000);
|
|
2375
2600
|
ch.once("end", () => { clearTimeout(t); res(); });
|
|
2376
2601
|
ch.once("error", () => { clearTimeout(t); res(); });
|
|
2377
2602
|
});
|
|
2378
2603
|
}
|
|
2379
|
-
|
|
2604
|
+
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2605
|
+
return [capSignal, oomSignal];
|
|
2380
2606
|
}
|
|
2381
2607
|
|
|
2382
2608
|
_teardownResult(type, message, started) {
|
|
@@ -2512,7 +2738,7 @@ class WarmBox {
|
|
|
2512
2738
|
this._born = Date.now();
|
|
2513
2739
|
this._spent = false;
|
|
2514
2740
|
this._rc = null;
|
|
2515
|
-
this.
|
|
2741
|
+
this._startedSig = Buffer.alloc(0); // KERN_STARTED_FD bytes: [started, cap enforcement, OOM outcome]
|
|
2516
2742
|
this._stderr = Buffer.alloc(0);
|
|
2517
2743
|
this._chunks = [];
|
|
2518
2744
|
this._total = 0;
|
|
@@ -2554,7 +2780,7 @@ class WarmBox {
|
|
|
2554
2780
|
}
|
|
2555
2781
|
const startedCh = this._child.stdio[3];
|
|
2556
2782
|
if (startedCh) {
|
|
2557
|
-
startedCh.on("data", (b) => {
|
|
2783
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2558
2784
|
startedCh.on("error", () => {});
|
|
2559
2785
|
}
|
|
2560
2786
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2739,18 +2965,26 @@ class WarmBox {
|
|
|
2739
2965
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
2740
2966
|
});
|
|
2741
2967
|
}
|
|
2742
|
-
const capSignal = this.
|
|
2968
|
+
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2743
2969
|
this.retire();
|
|
2744
|
-
if (looksLikeStartupFailure(err)) throw new SandboxError(err.trim() || "the box failed to start");
|
|
2745
2970
|
let type = "killed";
|
|
2746
2971
|
let dflt = "the box exited before the code finished";
|
|
2747
|
-
|
|
2972
|
+
// Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
|
|
2973
|
+
// the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
|
|
2974
|
+
// what keeps a pool box's OOM from being thrown as a box that never came up.
|
|
2975
|
+
if (oomSignal === 1 || kernReportedOom(err)) {
|
|
2748
2976
|
type = "oom";
|
|
2749
|
-
dflt = "the box
|
|
2977
|
+
dflt = "the box exceeded its memory cap and was OOM-killed";
|
|
2978
|
+
} else if (looksLikeStartupFailure(err)) {
|
|
2979
|
+
throw new SandboxError(err.trim() || "the box failed to start");
|
|
2750
2980
|
} else if (capSignal === 2) {
|
|
2751
2981
|
dflt =
|
|
2752
|
-
"the box was
|
|
2753
|
-
"so
|
|
2982
|
+
"the box was killed, and its memory cap was not enforced here (no cgroup delegation), " +
|
|
2983
|
+
"so no memory limit was in force to attribute it to";
|
|
2984
|
+
} else if (this._sbx.memoryMb !== null && this._sbx.memoryMb !== undefined) {
|
|
2985
|
+
dflt =
|
|
2986
|
+
"the box was killed and the kernel reported no OOM against its memory cap: an external kill " +
|
|
2987
|
+
"(`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory";
|
|
2754
2988
|
}
|
|
2755
2989
|
return this._result("", "", this._exitCode(), started, before, {
|
|
2756
2990
|
fault: { type, message: err.trim() || dflt },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|