kern-sandbox 0.1.43 → 0.2.1
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 +313 -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.1
|
|
39
|
+
const VERSION = "0.2.1";
|
|
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,96 @@ 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, 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. Mirrors `_oom_verdict`. */
|
|
984
|
+
function oomVerdict(oomSignal, stderr, kernWrotePayload) {
|
|
985
|
+
if (oomSignal !== null && oomSignal !== undefined) return oomSignal === 1;
|
|
986
|
+
if (!kernWrotePayload) return false;
|
|
987
|
+
return kernReportedOom(stderr);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function parseStartedBytes(buf) {
|
|
991
|
+
const b = buf || Buffer.alloc(0);
|
|
992
|
+
return {
|
|
993
|
+
boxStarted: b.length >= 1 && b[0] === 1,
|
|
994
|
+
capSignal: b.length >= 2 ? b[1] : 0,
|
|
995
|
+
oomSignal: b.length >= 3 ? b[2] : null,
|
|
996
|
+
workloadSignal: b.length >= 4 ? b[3] : null,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** The byte kern writes to `KERN_ALIVE_FD` the moment it accepts the descriptor, before any box setup.
|
|
1001
|
+
* It is what tells "the setup has not finished" from "this binary does not speak the protocol": an older
|
|
1002
|
+
* kern never writes to that pipe AND never closes it, so the pipe is open and silent in both cases.
|
|
1003
|
+
* Mirrors `_ALIVE_ACK`. */
|
|
1004
|
+
const ALIVE_ACK = 0x41; // 'A'
|
|
1005
|
+
|
|
1006
|
+
/** Where kern was when the deadline fired, read off the `KERN_ALIVE_FD` pipe. Spelled once so a caller
|
|
1007
|
+
* cannot invent a fourth answer. Mirrors the `_ALIVE_*` constants in the Python binding. */
|
|
1008
|
+
const ALIVE_PAST_SETUP = "past-setup"; // the workload ran (EOF at execvp), or kern reported setup failed
|
|
1009
|
+
const ALIVE_IN_SETUP = "in-setup"; // kern acknowledged the channel and is still BUILDING the box
|
|
1010
|
+
const ALIVE_UNKNOWN = "unknown"; // nothing on the pipe: a kern that predates this channel
|
|
1011
|
+
|
|
1012
|
+
/** True iff KERN said the kernel's OOM killer took this box against its own memory cap.
|
|
1013
|
+
*
|
|
1014
|
+
* The ONE definition of "this was an OOM", used by all three death paths (the one-shot exit-code
|
|
1015
|
+
* classifier, the resident kernel's death, and a pool box that died) so they cannot drift into
|
|
1016
|
+
* disagreeing about the same box. It is an OBSERVATION - kern reads `memory.events` and says so -
|
|
1017
|
+
* where the SDK can only infer, and the inference it replaced was MEASURED wrong in both directions:
|
|
1018
|
+
* `kern stop` during a cell was reported `oom`, while a real OOM on the resident kernel was reported
|
|
1019
|
+
* as a box that failed to start (and raised).
|
|
1020
|
+
*
|
|
1021
|
+
* Anchored on kern's `kern:` line prefix, which the real line carries (measured verbatim: `kern: the
|
|
1022
|
+
* workload was killed by the kernel's OOM killer against this box's own memory cap.`), MINUS the
|
|
1023
|
+
* benign diagnostics - `kern: note: <quoting the sentence>` is kern TALKING about an OOM, not
|
|
1024
|
+
* reporting one.
|
|
1025
|
+
*
|
|
1026
|
+
* THE FALLBACK, NOT THE AUTHORITY. Against a kern that writes the 3rd KERN_STARTED_FD byte the verdict
|
|
1027
|
+
* comes from there instead, on a pipe the workload never holds. This covers an older binary, and it is
|
|
1028
|
+
* forgeable in exactly one direction: a workload that writes the whole prefixed sentence itself turns its
|
|
1029
|
+
* own `killed` into `oom`. Both are sandbox faults, and timeout / blocked-escape are decided by exit code
|
|
1030
|
+
* before any text is read, so the worst case is a caller misleading itself about its own kill.
|
|
1031
|
+
* Mirrors `_kern_reported_oom`. */
|
|
1032
|
+
function kernReportedOom(stderr) {
|
|
1033
|
+
for (const line of String(stderr || "").split("\n")) {
|
|
1034
|
+
const s = line.replace(/^\s+/, "");
|
|
1035
|
+
if (s.startsWith("kern:") && !isKernDiagnostic(s) && s.includes(KERN_OOM_MARKER)) return true;
|
|
1036
|
+
}
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
888
1039
|
|
|
889
1040
|
function looksLikeStartupFailure(stderr) {
|
|
890
1041
|
const markers = [
|
|
@@ -898,9 +1049,13 @@ function looksLikeStartupFailure(stderr) {
|
|
|
898
1049
|
"error: oci:",
|
|
899
1050
|
"error: image:",
|
|
900
1051
|
];
|
|
1052
|
+
// The OOM sentence is skipped for a sharper reason than the benign notes: it is a report about a box
|
|
1053
|
+
// that RAN, and it is `kern:`-prefixed, so it used to satisfy this predicate. MEASURED, that is how a
|
|
1054
|
+
// real OOM on a resident kernel came back as `startup_failed` and was THROWN instead of returning an
|
|
1055
|
+
// `oom` fault.
|
|
901
1056
|
for (const line of stderr.split("\n")) {
|
|
902
1057
|
const s = line.replace(/^\s+/, "");
|
|
903
|
-
if (isKernDiagnostic(s)) continue;
|
|
1058
|
+
if (isKernDiagnostic(s) || kernReportedOom(s)) continue;
|
|
904
1059
|
if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
|
|
905
1060
|
}
|
|
906
1061
|
return false;
|
|
@@ -1424,32 +1579,71 @@ class Sandbox {
|
|
|
1424
1579
|
// OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
|
|
1425
1580
|
// compatible).
|
|
1426
1581
|
childEnv.KERN_STARTED_FD = "3";
|
|
1582
|
+
// A SECOND, LIVE channel, because the first one is post-mortem. kern writes KERN_STARTED_FD at the
|
|
1583
|
+
// box's TEARDOWN, so when OUR deadline fires we kill kern before that write and learn nothing: a
|
|
1584
|
+
// workload that was slow and a kern whose SETUP blocked are the same overrun. fd 4 carries kern's
|
|
1585
|
+
// readiness pipe: the ack byte on acceptance, EOF when the workload `execvp`s (the box child marks it
|
|
1586
|
+
// FD_CLOEXEC), one byte if setup or exec failed, and nothing at all from a kern that predates it.
|
|
1587
|
+
childEnv.KERN_ALIVE_FD = "4";
|
|
1427
1588
|
|
|
1428
1589
|
const started = process.hrtime.bigint();
|
|
1429
1590
|
return new Promise((resolve, reject) => {
|
|
1430
1591
|
let child;
|
|
1431
1592
|
let boxStarted = false;
|
|
1432
1593
|
let capSignal = 0; // 2nd started byte: 0 undetermined/old-kern, 1 memory cap enforced, 2 not enforced
|
|
1594
|
+
let oomSignal = null; // 3rd started byte: 1 = OOM-killed, 0 = not, null = this kern does not say
|
|
1595
|
+
let workloadSignal = null; // 4th started byte: the signal that killed the workload, 0 = it exited
|
|
1433
1596
|
try {
|
|
1434
1597
|
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
1435
1598
|
// The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
|
|
1436
1599
|
child = spawn(argv[0], argv.slice(1), {
|
|
1437
1600
|
env: childEnv,
|
|
1438
1601
|
detached: true,
|
|
1439
|
-
stdio: ["ignore", "pipe", "pipe", "pipe"],
|
|
1602
|
+
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
|
|
1440
1603
|
});
|
|
1441
1604
|
} catch (e) {
|
|
1442
1605
|
this._removeEnvFile(name);
|
|
1443
1606
|
return reject(new SandboxError(`could not spawn the box: ${e.message}`));
|
|
1444
1607
|
}
|
|
1445
1608
|
|
|
1609
|
+
// The alive channel's state, updated as the pipe speaks. Read at the deadline, BEFORE the kill:
|
|
1610
|
+
// the teardown closes every write end, so afterwards the pipe reads EOF whatever the box was doing
|
|
1611
|
+
// and the question answers itself wrongly.
|
|
1612
|
+
let aliveState = ALIVE_UNKNOWN;
|
|
1613
|
+
const aliveCh = child.stdio[4];
|
|
1614
|
+
if (aliveCh) {
|
|
1615
|
+
aliveCh.on("data", (b) => {
|
|
1616
|
+
if (aliveState === ALIVE_PAST_SETUP) return;
|
|
1617
|
+
for (const byte of b) {
|
|
1618
|
+
if (byte === ALIVE_ACK) {
|
|
1619
|
+
if (aliveState === ALIVE_UNKNOWN) aliveState = ALIVE_IN_SETUP;
|
|
1620
|
+
} else {
|
|
1621
|
+
aliveState = ALIVE_PAST_SETUP; // a byte beyond the ack: kern said setup failed
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
// EOF: the box child's FD_CLOEXEC closed it at `execvp`, so the workload ran.
|
|
1627
|
+
aliveCh.on("end", () => {
|
|
1628
|
+
aliveState = ALIVE_PAST_SETUP;
|
|
1629
|
+
});
|
|
1630
|
+
aliveCh.on("error", () => {});
|
|
1631
|
+
}
|
|
1446
1632
|
const startedCh = child.stdio[3];
|
|
1447
1633
|
if (startedCh) {
|
|
1448
1634
|
// 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.
|
|
1635
|
+
// (a NEWER kern only, same atomic write) = the memory-cap enforcement signal; absent = 0. Byte 2
|
|
1636
|
+
// (a NEWER kern still) = the OOM OUTCOME: 1 iff the kernel's OOM killer fired against this box's
|
|
1637
|
+
// OWN cgroup. Enforcement is not an outcome, and this byte is the only place the outcome arrives
|
|
1638
|
+
// on a channel the workload cannot write.
|
|
1639
|
+
// ACCUMULATED rather than read off one chunk: kern's write is atomic, so all four bytes arrive
|
|
1640
|
+
// together in practice, but a stream that split them would silently cost us the last one - and a
|
|
1641
|
+
// lost OOM byte reads as "no OOM", a lost signal byte as "nothing killed it", both wrong answers
|
|
1642
|
+
// arrived at invisibly.
|
|
1643
|
+
let sig = Buffer.alloc(0);
|
|
1450
1644
|
startedCh.on("data", (b) => {
|
|
1451
|
-
|
|
1452
|
-
|
|
1645
|
+
sig = Buffer.concat([sig, b]);
|
|
1646
|
+
({ boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(sig));
|
|
1453
1647
|
});
|
|
1454
1648
|
startedCh.on("error", () => {});
|
|
1455
1649
|
}
|
|
@@ -1476,7 +1670,10 @@ class Sandbox {
|
|
|
1476
1670
|
const stdout = out.buffer().toString("utf8");
|
|
1477
1671
|
const stderr = err.buffer().toString("utf8");
|
|
1478
1672
|
const rc = toRc(code, signal);
|
|
1479
|
-
let fault = this._classify(
|
|
1673
|
+
let fault = this._classify(
|
|
1674
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
|
|
1675
|
+
boxStarted,
|
|
1676
|
+
);
|
|
1480
1677
|
const execFail = execFailureBinary(stderr);
|
|
1481
1678
|
if (execFail !== null && rc !== 0) {
|
|
1482
1679
|
// BEFORE the suppression below, which would erase it: the box started, so that branch
|
|
@@ -1574,37 +1771,75 @@ class Sandbox {
|
|
|
1574
1771
|
}
|
|
1575
1772
|
}
|
|
1576
1773
|
|
|
1577
|
-
_classify(
|
|
1774
|
+
_classify(
|
|
1775
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = null, aliveState = ALIVE_UNKNOWN,
|
|
1776
|
+
workloadSignal = null, kernWrotePayload = false,
|
|
1777
|
+
) {
|
|
1578
1778
|
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
1579
1779
|
// heuristic, because stderr is a channel the workload controls.
|
|
1580
|
-
if (timedOut)
|
|
1780
|
+
if (timedOut) {
|
|
1781
|
+
// AND THE DEADLINE ALONE DOES NOT SAY WHOSE FAULT IT WAS. `ALIVE_IN_SETUP` is kern's own answer,
|
|
1782
|
+
// read off the readiness pipe while kern was still alive: the box was still being BUILT, so the
|
|
1783
|
+
// code never ran and calling this a `timeout` would tell the caller their workload was slow. That
|
|
1784
|
+
// class was measured with a FIFO volume source (404 seconds in `wait_for_partner`), and the shapes
|
|
1785
|
+
// behind it - an `lstat` on a dead NFS mount, a FUSE whose daemon is gone - are not FIFOs and
|
|
1786
|
+
// cannot be refused by type. Every other state keeps the old verdict, `ALIVE_UNKNOWN` (an older
|
|
1787
|
+
// kern) included: absence of evidence is not evidence.
|
|
1788
|
+
if (aliveState === ALIVE_IN_SETUP)
|
|
1789
|
+
return sandboxFault(
|
|
1790
|
+
"startup_failed",
|
|
1791
|
+
`the box never started: kern was still setting it up when the ${timeoutS ?? this.timeoutS}s ` +
|
|
1792
|
+
"deadline fired, so the code never ran. A host path that blocks is what does this - a bind " +
|
|
1793
|
+
"source on a dead NFS or a FUSE mount whose daemon is gone, an image layer on a stalled " +
|
|
1794
|
+
"disk - and the remedy is that path, not a longer timeout",
|
|
1795
|
+
);
|
|
1581
1796
|
return sandboxFault(
|
|
1582
1797
|
"timeout",
|
|
1583
1798
|
`exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
|
|
1584
1799
|
);
|
|
1585
|
-
|
|
1800
|
+
}
|
|
1801
|
+
// THE EXIT CODE IS THE RIGHT THING TO PROPAGATE AND THE WRONG THING TO CLASSIFY FROM: kern reports
|
|
1802
|
+
// the workload's status as `128 + N`, so a workload the kernel killed and one that called `exit(137)`
|
|
1803
|
+
// are the same number. MEASURED through the Python binding: `sys.exit(137)` came back `killed` with a
|
|
1804
|
+
// message about an external kill that never happened, and `sys.exit(159)` came back `escape_blocked`,
|
|
1805
|
+
// a security event a cell could fabricate in one line. kern's 4th started-byte carries the signal.
|
|
1806
|
+
// `null` (an older kern, or a kern our teardown killed first) keeps the old exit-code reading:
|
|
1807
|
+
// absence of evidence is not evidence. `signal === "SIG..."` is a different question, kern ITSELF
|
|
1808
|
+
// being signalled, and stays as it was.
|
|
1809
|
+
const killedBy = (n) => workloadSignal === null || workloadSignal === n;
|
|
1810
|
+
if ((rc === EXIT_SIGSYS && killedBy(SIG_SYS)) || signal === "SIGSYS")
|
|
1586
1811
|
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
|
-
|
|
1812
|
+
if ((rc === EXIT_SIGKILL && killedBy(SIG_KILL)) || signal === "SIGKILL") {
|
|
1813
|
+
// Only kern's own OOM sentence buys the `oom` label (`kernReportedOom`, the one definition shared
|
|
1814
|
+
// with the resident-kernel and pool death paths).
|
|
1815
|
+
//
|
|
1816
|
+
// WHAT THIS REPLACED, because the replaced version read as sound: a SIGKILL of a memory-capped box
|
|
1817
|
+
// was called the cgroup OOM-killer, which is what a breached memory.max does (kern sets
|
|
1818
|
+
// memory.oom.group=1, so the whole box goes at once). MEASURED: `kern stop` during a cell returns
|
|
1819
|
+
// 137, so it came back `oom`, and an agent branching on the fault would retry with MORE MEMORY a
|
|
1820
|
+
// kill that had nothing to do with memory. A confident wrong answer is worse than no answer.
|
|
1821
|
+
if (oomVerdict(oomSignal, stderr, kernWrotePayload))
|
|
1596
1822
|
return sandboxFault(
|
|
1597
1823
|
"oom",
|
|
1598
1824
|
"the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
|
|
1599
1825
|
);
|
|
1826
|
+
// `capSignal` (kern's UNFORGEABLE enforcement byte: 1 = enforced, 2 = requested but NOT enforced
|
|
1827
|
+
// here, 0 = undetermined) no longer decides the TYPE - a SIGKILL on a capped box is not evidence of
|
|
1828
|
+
// an OOM, whatever the byte says - and a 2 still earns its own sentence, because "your cap was not
|
|
1829
|
+
// in force here" is the one thing the caller cannot find out for itself.
|
|
1600
1830
|
if (capSignal === 2)
|
|
1601
1831
|
return sandboxFault(
|
|
1602
1832
|
"killed",
|
|
1603
|
-
"the box was SIGKILLed,
|
|
1833
|
+
"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",
|
|
1834
|
+
);
|
|
1835
|
+
if (this.memoryMb !== null)
|
|
1836
|
+
return sandboxFault(
|
|
1837
|
+
"killed",
|
|
1838
|
+
"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
1839
|
);
|
|
1605
1840
|
return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
|
|
1606
1841
|
}
|
|
1607
|
-
if (rc === EXIT_SIGTERM || signal === "SIGTERM")
|
|
1842
|
+
if ((rc === EXIT_SIGTERM && killedBy(SIG_TERM)) || signal === "SIGTERM")
|
|
1608
1843
|
return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
|
|
1609
1844
|
// Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
|
|
1610
1845
|
// PARENT before the box runs). kern's box-not-started paths BOTH exit 125 AND print a `kern:` marker,
|
|
@@ -2164,10 +2399,12 @@ class Kernel {
|
|
|
2164
2399
|
this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
|
|
2165
2400
|
this._stderr = Buffer.alloc(0);
|
|
2166
2401
|
this._dead = false;
|
|
2167
|
-
// kern's
|
|
2168
|
-
//
|
|
2169
|
-
// detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
2170
|
-
|
|
2402
|
+
// kern's KERN_STARTED_FD bytes for a RESIDENT box: the enforcement byte (2nd) and the OOM-outcome
|
|
2403
|
+
// byte (3rd). kern writes them only at box teardown (a cell kills the kernel), so they arrive
|
|
2404
|
+
// ~concurrent with the death we detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
2405
|
+
// Kept as the raw buffer because the bytes arrive in ONE atomic write and a stream is free to deliver
|
|
2406
|
+
// it in pieces. Absent bytes read as 0 = undetermined / old kern.
|
|
2407
|
+
this._startedSig = Buffer.alloc(0);
|
|
2171
2408
|
}
|
|
2172
2409
|
|
|
2173
2410
|
async _open() {
|
|
@@ -2194,7 +2431,7 @@ class Kernel {
|
|
|
2194
2431
|
});
|
|
2195
2432
|
const startedCh = this._child.stdio[3];
|
|
2196
2433
|
if (startedCh) {
|
|
2197
|
-
startedCh.on("data", (b) => {
|
|
2434
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2198
2435
|
startedCh.on("error", () => {});
|
|
2199
2436
|
}
|
|
2200
2437
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2295,7 +2532,7 @@ class Kernel {
|
|
|
2295
2532
|
return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
|
|
2296
2533
|
if (reply === null) {
|
|
2297
2534
|
const err = this._stderr.toString("utf8");
|
|
2298
|
-
const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
|
|
2535
|
+
const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
|
|
2299
2536
|
return this._teardownResult(kind, err.trim() || dflt, started);
|
|
2300
2537
|
}
|
|
2301
2538
|
return this._resultFromReply(reply, started);
|
|
@@ -2342,41 +2579,53 @@ class Kernel {
|
|
|
2342
2579
|
});
|
|
2343
2580
|
}
|
|
2344
2581
|
|
|
2345
|
-
/** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`.
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2350
|
-
*
|
|
2351
|
-
*
|
|
2352
|
-
|
|
2582
|
+
/** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`. The runCode counterpart of
|
|
2583
|
+
* the one-shot _classify SIGKILL branch: a kernel death has no per-cell exit code, so the whole verdict
|
|
2584
|
+
* is made here from what kern wrote.
|
|
2585
|
+
*
|
|
2586
|
+
* ORDER, and it was measured wrong before: kern's OOM sentence is asked about FIRST, because it is
|
|
2587
|
+
* `kern:`-prefixed and so was also matching the box-did-not-start heuristic below. A real OOM on a
|
|
2588
|
+
* resident kernel therefore came back `startup_failed`, which `_teardownResult` THROWS - so the
|
|
2589
|
+
* flagship path could not produce an `oom` fault at all, while an external `kern stop` DID produce one
|
|
2590
|
+
* from the memoryMb inference. Two defects pointing opposite ways.
|
|
2591
|
+
*
|
|
2592
|
+
* `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
|
|
2593
|
+
* requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
|
|
2594
|
+
* "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
|
+
if (oomVerdict(oomSignal, err, kernWrotePayload)) return ["oom", "the kernel box exceeded its memory cap and was OOM-killed"];
|
|
2353
2597
|
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
2598
|
if (capSignal === 2)
|
|
2357
2599
|
return [
|
|
2358
2600
|
"killed",
|
|
2359
|
-
"the kernel box was
|
|
2601
|
+
"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",
|
|
2602
|
+
];
|
|
2603
|
+
if (this._sbx.memoryMb !== null)
|
|
2604
|
+
return [
|
|
2605
|
+
"killed",
|
|
2606
|
+
"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
2607
|
];
|
|
2361
2608
|
return ["killed", "the kernel box exited"];
|
|
2362
2609
|
}
|
|
2363
2610
|
|
|
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,
|
|
2611
|
+
/** kern's enforcement and OOM-outcome bytes for the resident box, read ONCE on kernel death, as
|
|
2612
|
+
* `[capSignal, oomSignal]`. kern writes the KERN_STARTED_FD signal only at box teardown (a resident box
|
|
2613
|
+
* exits when a cell kills it), ~concurrent with the death detected on stdout. The fd-3 `data` handler in
|
|
2614
|
+
* `_open` accumulates the bytes as they arrive; this awaits a BOUNDED window (the fd's own `end`, or
|
|
2615
|
+
* 1 s) so the read is deterministic rather than a race. `[0, 0]` on EOF / an old kern / not yet, which
|
|
2616
|
+
* falls back to kern's stderr sentence. */
|
|
2369
2617
|
async _readCapSignal() {
|
|
2370
2618
|
const ch = this._child && this._child.stdio && this._child.stdio[3];
|
|
2371
|
-
if (!ch) return 0;
|
|
2372
|
-
if (this.
|
|
2619
|
+
if (!ch) return [0, null, false];
|
|
2620
|
+
if (this._startedSig.length < 3 && !ch.destroyed) {
|
|
2373
2621
|
await new Promise((res) => {
|
|
2374
2622
|
const t = setTimeout(res, 1000);
|
|
2375
2623
|
ch.once("end", () => { clearTimeout(t); res(); });
|
|
2376
2624
|
ch.once("error", () => { clearTimeout(t); res(); });
|
|
2377
2625
|
});
|
|
2378
2626
|
}
|
|
2379
|
-
|
|
2627
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2628
|
+
return [capSignal, oomSignal, boxStarted];
|
|
2380
2629
|
}
|
|
2381
2630
|
|
|
2382
2631
|
_teardownResult(type, message, started) {
|
|
@@ -2512,7 +2761,7 @@ class WarmBox {
|
|
|
2512
2761
|
this._born = Date.now();
|
|
2513
2762
|
this._spent = false;
|
|
2514
2763
|
this._rc = null;
|
|
2515
|
-
this.
|
|
2764
|
+
this._startedSig = Buffer.alloc(0); // KERN_STARTED_FD bytes: [started, cap enforcement, OOM outcome]
|
|
2516
2765
|
this._stderr = Buffer.alloc(0);
|
|
2517
2766
|
this._chunks = [];
|
|
2518
2767
|
this._total = 0;
|
|
@@ -2554,7 +2803,7 @@ class WarmBox {
|
|
|
2554
2803
|
}
|
|
2555
2804
|
const startedCh = this._child.stdio[3];
|
|
2556
2805
|
if (startedCh) {
|
|
2557
|
-
startedCh.on("data", (b) => {
|
|
2806
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2558
2807
|
startedCh.on("error", () => {});
|
|
2559
2808
|
}
|
|
2560
2809
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2739,18 +2988,26 @@ class WarmBox {
|
|
|
2739
2988
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
2740
2989
|
});
|
|
2741
2990
|
}
|
|
2742
|
-
const capSignal = this.
|
|
2991
|
+
const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2743
2992
|
this.retire();
|
|
2744
|
-
if (looksLikeStartupFailure(err)) throw new SandboxError(err.trim() || "the box failed to start");
|
|
2745
2993
|
let type = "killed";
|
|
2746
2994
|
let dflt = "the box exited before the code finished";
|
|
2747
|
-
|
|
2995
|
+
// Same order, and for the same measured reason, as `_kernelDeathFault`: kern's OOM sentence carries
|
|
2996
|
+
// the `kern:` prefix that `looksLikeStartupFailure` matches on, so asking about the start SECOND is
|
|
2997
|
+
// what keeps a pool box's OOM from being thrown as a box that never came up.
|
|
2998
|
+
if (oomVerdict(oomSignal, err, boxStarted)) {
|
|
2748
2999
|
type = "oom";
|
|
2749
|
-
dflt = "the box
|
|
3000
|
+
dflt = "the box exceeded its memory cap and was OOM-killed";
|
|
3001
|
+
} else if (looksLikeStartupFailure(err)) {
|
|
3002
|
+
throw new SandboxError(err.trim() || "the box failed to start");
|
|
2750
3003
|
} else if (capSignal === 2) {
|
|
2751
3004
|
dflt =
|
|
2752
|
-
"the box was
|
|
2753
|
-
"so
|
|
3005
|
+
"the box was killed, and its memory cap was not enforced here (no cgroup delegation), " +
|
|
3006
|
+
"so no memory limit was in force to attribute it to";
|
|
3007
|
+
} else if (this._sbx.memoryMb !== null && this._sbx.memoryMb !== undefined) {
|
|
3008
|
+
dflt =
|
|
3009
|
+
"the box was killed and the kernel reported no OOM against its memory cap: an external kill " +
|
|
3010
|
+
"(`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory";
|
|
2754
3011
|
}
|
|
2755
3012
|
return this._result("", "", this._exitCode(), started, before, {
|
|
2756
3013
|
fault: { type, message: err.trim() || dflt },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|