kern-sandbox 0.1.42 → 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 +303 -60
- 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
|
}
|
|
@@ -594,12 +656,21 @@ function findKern() {
|
|
|
594
656
|
throw new SandboxError(
|
|
595
657
|
"the `kern` binary was not found on PATH, and this is macOS: kern is Linux-only " +
|
|
596
658
|
"(no namespaces, no cgroups on a Mac), so there is no macOS build to find. " +
|
|
597
|
-
"Run inside a Linux VM (colima, Lima, OrbStack, UTM)
|
|
598
|
-
"
|
|
659
|
+
"Run inside a Linux VM (colima, Lima, OrbStack, UTM) and install it there with:\n" +
|
|
660
|
+
" curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
|
|
661
|
+
"or set $KERN_BIN to a kern reachable from here.",
|
|
599
662
|
);
|
|
663
|
+
// THE COMMAND, NOT A LINK. `npm install kern-sandbox` does NOT bring the binary: this package is a
|
|
664
|
+
// wrapper around a process it does not ship, and the moment a user meets that fact is this error.
|
|
665
|
+
// It used to answer with a repository URL, which asks someone one paste away from working to go
|
|
666
|
+
// and read a page first. The same sentence the Python binding gives, deliberately: two wrappers
|
|
667
|
+
// around one runtime must not disagree about how to get it, and the installer line is the one the
|
|
668
|
+
// project's README leads with.
|
|
600
669
|
throw new SandboxError(
|
|
601
|
-
"the `kern` binary was not found on PATH
|
|
602
|
-
"
|
|
670
|
+
"the `kern` binary was not found on PATH. `npm install kern-sandbox` installs this wrapper, " +
|
|
671
|
+
"not the runtime it drives - install kern with:\n" +
|
|
672
|
+
" curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh\n" +
|
|
673
|
+
"or point $KERN_BIN at a kern you already have.",
|
|
603
674
|
);
|
|
604
675
|
}
|
|
605
676
|
|
|
@@ -875,7 +946,74 @@ function isKernDiagnostic(line) {
|
|
|
875
946
|
return KERN_DIAGNOSTICS.some((p) => s.startsWith(p));
|
|
876
947
|
}
|
|
877
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";
|
|
878
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
|
+
}
|
|
879
1017
|
|
|
880
1018
|
function looksLikeStartupFailure(stderr) {
|
|
881
1019
|
const markers = [
|
|
@@ -889,9 +1027,13 @@ function looksLikeStartupFailure(stderr) {
|
|
|
889
1027
|
"error: oci:",
|
|
890
1028
|
"error: image:",
|
|
891
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.
|
|
892
1034
|
for (const line of stderr.split("\n")) {
|
|
893
1035
|
const s = line.replace(/^\s+/, "");
|
|
894
|
-
if (isKernDiagnostic(s)) continue;
|
|
1036
|
+
if (isKernDiagnostic(s) || kernReportedOom(s)) continue;
|
|
895
1037
|
if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
|
|
896
1038
|
}
|
|
897
1039
|
return false;
|
|
@@ -1415,32 +1557,71 @@ class Sandbox {
|
|
|
1415
1557
|
// OLD kern never writes it, `boxStarted` stays false, and the stderr heuristic stands (backward
|
|
1416
1558
|
// compatible).
|
|
1417
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";
|
|
1418
1566
|
|
|
1419
1567
|
const started = process.hrtime.bigint();
|
|
1420
1568
|
return new Promise((resolve, reject) => {
|
|
1421
1569
|
let child;
|
|
1422
1570
|
let boxStarted = false;
|
|
1423
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
|
|
1424
1574
|
try {
|
|
1425
1575
|
// detached: own process group, so we can signal the box + kern as a unit (killpg).
|
|
1426
1576
|
// The 4th stdio slot is fd 3: the child (kern) writes the started byte, the parent reads it.
|
|
1427
1577
|
child = spawn(argv[0], argv.slice(1), {
|
|
1428
1578
|
env: childEnv,
|
|
1429
1579
|
detached: true,
|
|
1430
|
-
stdio: ["ignore", "pipe", "pipe", "pipe"],
|
|
1580
|
+
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
|
|
1431
1581
|
});
|
|
1432
1582
|
} catch (e) {
|
|
1433
1583
|
this._removeEnvFile(name);
|
|
1434
1584
|
return reject(new SandboxError(`could not spawn the box: ${e.message}`));
|
|
1435
1585
|
}
|
|
1436
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
|
+
}
|
|
1437
1610
|
const startedCh = child.stdio[3];
|
|
1438
1611
|
if (startedCh) {
|
|
1439
1612
|
// Byte 0 (0x01) = the box started; stream end with no byte = never started / old kern. Byte 1
|
|
1440
|
-
// (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);
|
|
1441
1622
|
startedCh.on("data", (b) => {
|
|
1442
|
-
|
|
1443
|
-
|
|
1623
|
+
sig = Buffer.concat([sig, b]);
|
|
1624
|
+
({ boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(sig));
|
|
1444
1625
|
});
|
|
1445
1626
|
startedCh.on("error", () => {});
|
|
1446
1627
|
}
|
|
@@ -1467,7 +1648,9 @@ class Sandbox {
|
|
|
1467
1648
|
const stdout = out.buffer().toString("utf8");
|
|
1468
1649
|
const stderr = err.buffer().toString("utf8");
|
|
1469
1650
|
const rc = toRc(code, signal);
|
|
1470
|
-
let fault = this._classify(
|
|
1651
|
+
let fault = this._classify(
|
|
1652
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal, oomSignal, aliveState, workloadSignal,
|
|
1653
|
+
);
|
|
1471
1654
|
const execFail = execFailureBinary(stderr);
|
|
1472
1655
|
if (execFail !== null && rc !== 0) {
|
|
1473
1656
|
// BEFORE the suppression below, which would erase it: the box started, so that branch
|
|
@@ -1565,37 +1748,75 @@ class Sandbox {
|
|
|
1565
1748
|
}
|
|
1566
1749
|
}
|
|
1567
1750
|
|
|
1568
|
-
_classify(
|
|
1751
|
+
_classify(
|
|
1752
|
+
rc, signal, stderr, timedOut, timeoutS, capSignal = 0, oomSignal = 0, aliveState = ALIVE_UNKNOWN,
|
|
1753
|
+
workloadSignal = null,
|
|
1754
|
+
) {
|
|
1569
1755
|
// ORDER IS A SECURITY PROPERTY: deterministic-by-exit-code classes are decided BEFORE the stderr
|
|
1570
1756
|
// heuristic, because stderr is a channel the workload controls.
|
|
1571
|
-
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
|
+
);
|
|
1572
1773
|
return sandboxFault(
|
|
1573
1774
|
"timeout",
|
|
1574
1775
|
`exceeded the ${timeoutS ?? this.timeoutS}s time limit (killed by the binding)`,
|
|
1575
1776
|
);
|
|
1576
|
-
|
|
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")
|
|
1577
1788
|
return sandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)");
|
|
1578
|
-
if (rc === EXIT_SIGKILL || signal === "SIGKILL") {
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
|
|
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))
|
|
1587
1799
|
return sandboxFault(
|
|
1588
1800
|
"oom",
|
|
1589
1801
|
"the box exceeded its memory cap and was OOM-killed (SIGKILL, exit 137)" + this._scratchNote(),
|
|
1590
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.
|
|
1591
1807
|
if (capSignal === 2)
|
|
1592
1808
|
return sandboxFault(
|
|
1593
1809
|
"killed",
|
|
1594
|
-
"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",
|
|
1595
1816
|
);
|
|
1596
1817
|
return sandboxFault("killed", "the box was killed (SIGKILL); no memory cap was set to attribute it to OOM");
|
|
1597
1818
|
}
|
|
1598
|
-
if (rc === EXIT_SIGTERM || signal === "SIGTERM")
|
|
1819
|
+
if ((rc === EXIT_SIGTERM && killedBy(SIG_TERM)) || signal === "SIGTERM")
|
|
1599
1820
|
return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
|
|
1600
1821
|
// Box-not-started: a non-zero exit whose stderr carries kern's OWN setup markers (printed by the
|
|
1601
1822
|
// PARENT before the box runs). kern's box-not-started paths BOTH exit 125 AND print a `kern:` marker,
|
|
@@ -2155,10 +2376,12 @@ class Kernel {
|
|
|
2155
2376
|
this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
|
|
2156
2377
|
this._stderr = Buffer.alloc(0);
|
|
2157
2378
|
this._dead = false;
|
|
2158
|
-
// kern's
|
|
2159
|
-
//
|
|
2160
|
-
// detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
2161
|
-
|
|
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);
|
|
2162
2385
|
}
|
|
2163
2386
|
|
|
2164
2387
|
async _open() {
|
|
@@ -2185,7 +2408,7 @@ class Kernel {
|
|
|
2185
2408
|
});
|
|
2186
2409
|
const startedCh = this._child.stdio[3];
|
|
2187
2410
|
if (startedCh) {
|
|
2188
|
-
startedCh.on("data", (b) => {
|
|
2411
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2189
2412
|
startedCh.on("error", () => {});
|
|
2190
2413
|
}
|
|
2191
2414
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2286,7 +2509,7 @@ class Kernel {
|
|
|
2286
2509
|
return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
|
|
2287
2510
|
if (reply === null) {
|
|
2288
2511
|
const err = this._stderr.toString("utf8");
|
|
2289
|
-
const [kind, dflt] = this._kernelDeathFault(err, await this._readCapSignal());
|
|
2512
|
+
const [kind, dflt] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
|
|
2290
2513
|
return this._teardownResult(kind, err.trim() || dflt, started);
|
|
2291
2514
|
}
|
|
2292
2515
|
return this._resultFromReply(reply, started);
|
|
@@ -2333,41 +2556,53 @@ class Kernel {
|
|
|
2333
2556
|
});
|
|
2334
2557
|
}
|
|
2335
2558
|
|
|
2336
|
-
/** Why the resident kernel box died mid-cell, as `[type, defaultMessage]`.
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2343
|
-
|
|
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"];
|
|
2344
2574
|
if (looksLikeStartupFailure(err)) return ["startup_failed", "the kernel box failed to start"];
|
|
2345
|
-
if (this._sbx.memoryMb !== null && capSignal !== 2)
|
|
2346
|
-
return ["oom", "the kernel box was OOM-killed (it exceeded its memory cap)"];
|
|
2347
2575
|
if (capSignal === 2)
|
|
2348
2576
|
return [
|
|
2349
2577
|
"killed",
|
|
2350
|
-
"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",
|
|
2351
2584
|
];
|
|
2352
2585
|
return ["killed", "the kernel box exited"];
|
|
2353
2586
|
}
|
|
2354
2587
|
|
|
2355
|
-
/** kern's
|
|
2356
|
-
* the
|
|
2357
|
-
* ~concurrent with the death detected on stdout. The fd-3 `data` handler in
|
|
2358
|
-
* as
|
|
2359
|
-
* 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. */
|
|
2360
2594
|
async _readCapSignal() {
|
|
2361
2595
|
const ch = this._child && this._child.stdio && this._child.stdio[3];
|
|
2362
|
-
if (!ch) return 0;
|
|
2363
|
-
if (this.
|
|
2596
|
+
if (!ch) return [0, 0];
|
|
2597
|
+
if (this._startedSig.length < 3 && !ch.destroyed) {
|
|
2364
2598
|
await new Promise((res) => {
|
|
2365
2599
|
const t = setTimeout(res, 1000);
|
|
2366
2600
|
ch.once("end", () => { clearTimeout(t); res(); });
|
|
2367
2601
|
ch.once("error", () => { clearTimeout(t); res(); });
|
|
2368
2602
|
});
|
|
2369
2603
|
}
|
|
2370
|
-
|
|
2604
|
+
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2605
|
+
return [capSignal, oomSignal];
|
|
2371
2606
|
}
|
|
2372
2607
|
|
|
2373
2608
|
_teardownResult(type, message, started) {
|
|
@@ -2503,7 +2738,7 @@ class WarmBox {
|
|
|
2503
2738
|
this._born = Date.now();
|
|
2504
2739
|
this._spent = false;
|
|
2505
2740
|
this._rc = null;
|
|
2506
|
-
this.
|
|
2741
|
+
this._startedSig = Buffer.alloc(0); // KERN_STARTED_FD bytes: [started, cap enforcement, OOM outcome]
|
|
2507
2742
|
this._stderr = Buffer.alloc(0);
|
|
2508
2743
|
this._chunks = [];
|
|
2509
2744
|
this._total = 0;
|
|
@@ -2545,7 +2780,7 @@ class WarmBox {
|
|
|
2545
2780
|
}
|
|
2546
2781
|
const startedCh = this._child.stdio[3];
|
|
2547
2782
|
if (startedCh) {
|
|
2548
|
-
startedCh.on("data", (b) => {
|
|
2783
|
+
startedCh.on("data", (b) => { this._startedSig = Buffer.concat([this._startedSig, b]); });
|
|
2549
2784
|
startedCh.on("error", () => {});
|
|
2550
2785
|
}
|
|
2551
2786
|
this._child.on("error", () => { this._dead = true; this._flush(null); });
|
|
@@ -2730,18 +2965,26 @@ class WarmBox {
|
|
|
2730
2965
|
fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
|
|
2731
2966
|
});
|
|
2732
2967
|
}
|
|
2733
|
-
const capSignal = this.
|
|
2968
|
+
const { capSignal, oomSignal } = parseStartedBytes(this._startedSig);
|
|
2734
2969
|
this.retire();
|
|
2735
|
-
if (looksLikeStartupFailure(err)) throw new SandboxError(err.trim() || "the box failed to start");
|
|
2736
2970
|
let type = "killed";
|
|
2737
2971
|
let dflt = "the box exited before the code finished";
|
|
2738
|
-
|
|
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)) {
|
|
2739
2976
|
type = "oom";
|
|
2740
|
-
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");
|
|
2741
2980
|
} else if (capSignal === 2) {
|
|
2742
2981
|
dflt =
|
|
2743
|
-
"the box was
|
|
2744
|
-
"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";
|
|
2745
2988
|
}
|
|
2746
2989
|
return this._result("", "", this._exitCode(), started, before, {
|
|
2747
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",
|