kern-sandbox 0.1.12 → 0.1.14
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 +6 -2
- package/index.js +72 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**[kern](https://github.com/getkern/kern)** is a fast, rootless sandbox and virtual resource
|
|
4
4
|
runtime for any workload, including untrusted and AI-generated code: a real, kernel-enforced box
|
|
5
|
-
that starts in **3.
|
|
5
|
+
that starts in **3.4 ms** from an OCI image, out of one **~1.8 MB** binary, with no daemon.
|
|
6
6
|
**kern-sandbox**
|
|
7
7
|
is its Node / TypeScript binding: run untrusted or agent-generated code in a fresh, isolated box, from Node.
|
|
8
8
|
|
|
@@ -134,6 +134,10 @@ new Sandbox({
|
|
|
134
134
|
pids, // default 256
|
|
135
135
|
timeoutS, // default 30, MANDATORY per-call deadline
|
|
136
136
|
network, // default false (RELAXES ISOLATION)
|
|
137
|
+
capDrop, // default ["ALL"]: capabilities dropped from every box. kern always drops
|
|
138
|
+
// 13 dangerous ones; this drops the rest, which were held over the box's own
|
|
139
|
+
// user namespace. Pass [] to keep them (needed only if the workload binds a
|
|
140
|
+
// port below 1024 INSIDE the box).
|
|
137
141
|
mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
|
|
138
142
|
profiles, // reusable kern.toml profiles: ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]
|
|
139
143
|
env, // { KEY: "value" }
|
|
@@ -177,7 +181,7 @@ can still WRITE an artifact to the workspace and `readFile` it if you prefer.
|
|
|
177
181
|
**Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
|
|
178
182
|
CPython boot (~12 ms) every call. When you run many cells that share state (a REPL, a notebook, an
|
|
179
183
|
agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
|
|
180
|
-
In-memory state persists across cells and the per-cell cost drops from ~
|
|
184
|
+
In-memory state persists across cells and the per-cell cost drops from ~14 ms to **sub-millisecond**
|
|
181
185
|
(~300x). Same rich `results` capture as `runCode`.
|
|
182
186
|
|
|
183
187
|
```js
|
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.1.14";
|
|
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
|
|
@@ -552,6 +552,12 @@ function validateProfile(token) {
|
|
|
552
552
|
// re-validates and SSRF-checks the resolved IPs; this is the binding's first gate.
|
|
553
553
|
const DOMAIN_RE = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
|
554
554
|
|
|
555
|
+
// A Linux capability name for `kern box --cap-drop`, with or without the CAP_ prefix, or the literal
|
|
556
|
+
// ALL. Underscore-JOINED uppercase segments rather than "any of [A-Z0-9_]": the looser form accepts
|
|
557
|
+
// "CAP_", because the optional prefix does not have to consume it. Not a way to smuggle a flag, but
|
|
558
|
+
// a name kern rejects at box start, and validating here exists to fail at construction instead.
|
|
559
|
+
const CAP_RE = /^(?=.{1,32}$)(?:CAP_)?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
|
560
|
+
|
|
555
561
|
/** Validate one egress-allowlist domain (an FQDN like "pypi.org") before it reaches the argv. */
|
|
556
562
|
function validateDomain(domain) {
|
|
557
563
|
if (typeof domain !== "string" || !DOMAIN_RE.test(domain))
|
|
@@ -562,6 +568,16 @@ function validateDomain(domain) {
|
|
|
562
568
|
return domain;
|
|
563
569
|
}
|
|
564
570
|
|
|
571
|
+
/** Validate one capability name for `--cap-drop` before it reaches the argv. */
|
|
572
|
+
function validateCap(name) {
|
|
573
|
+
if (typeof name !== "string" || !CAP_RE.test(name))
|
|
574
|
+
throw new SandboxError(
|
|
575
|
+
`invalid capability ${JSON.stringify(name)}: expected 'ALL' or an uppercase capability name ` +
|
|
576
|
+
"such as 'NET_BIND_SERVICE' or 'CAP_NET_BIND_SERVICE'",
|
|
577
|
+
);
|
|
578
|
+
return name;
|
|
579
|
+
}
|
|
580
|
+
|
|
565
581
|
/** Map a Node close event {code, signal} to a unix-style rc (128 + signum for a signal). */
|
|
566
582
|
function toRc(code, signal) {
|
|
567
583
|
if (typeof code === "number") return code;
|
|
@@ -765,6 +781,13 @@ class Sandbox {
|
|
|
765
781
|
this.onStderr = opts.onStderr ?? null;
|
|
766
782
|
this.enforceLimits = opts.enforceLimits ?? true;
|
|
767
783
|
this.depsReadonly = opts.depsReadonly ?? false;
|
|
784
|
+
// Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
|
|
785
|
+
// The default drops the lot: kern already drops 13 dangerous capabilities unconditionally, but the
|
|
786
|
+
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
787
|
+
// running code nobody has read. Defence in depth rather than the boundary itself, and measured to
|
|
788
|
+
// cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
|
|
789
|
+
// needs CAP_NET_BIND_SERVICE. Pass `capDrop: []` for the previous behaviour.
|
|
790
|
+
this.capDrop = opts.capDrop ?? ["ALL"];
|
|
768
791
|
// trackFiles=true populates result.files by walking the workspace before AND after each call (O(N)
|
|
769
792
|
// in file count); a long session that accretes files slows every runCode. false = result.files [], O(1).
|
|
770
793
|
this.trackFiles = opts.trackFiles ?? true;
|
|
@@ -790,6 +813,17 @@ class Sandbox {
|
|
|
790
813
|
this._mountArgs.push("-v", ro ? `${real}:${tgt}:ro` : `${real}:${tgt}`);
|
|
791
814
|
}
|
|
792
815
|
}
|
|
816
|
+
// A bare string has a .map-less shape here, but Array.from("ALL") would yield ["A","L","L"] and
|
|
817
|
+
// three bogus flags, so refuse the string by name and say what to write instead.
|
|
818
|
+
if (typeof this.capDrop === "string")
|
|
819
|
+
throw new SandboxError(
|
|
820
|
+
`capDrop must be an array of names, not a bare string: write capDrop: [${JSON.stringify(
|
|
821
|
+
this.capDrop,
|
|
822
|
+
)}] for one, or capDrop: [] to drop none`,
|
|
823
|
+
);
|
|
824
|
+
if (!Array.isArray(this.capDrop))
|
|
825
|
+
throw new SandboxError("capDrop must be an array of capability names");
|
|
826
|
+
this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
|
|
793
827
|
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
794
828
|
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
795
829
|
if (this._egressAllow.length && this.network)
|
|
@@ -881,6 +915,7 @@ class Sandbox {
|
|
|
881
915
|
}
|
|
882
916
|
}
|
|
883
917
|
// kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
|
|
918
|
+
argv.push(...this._capDropArgs);
|
|
884
919
|
argv.push("--timeout", String(Math.floor(timeoutS) + 5));
|
|
885
920
|
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
886
921
|
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
|
@@ -968,19 +1003,16 @@ class Sandbox {
|
|
|
968
1003
|
const err = cappedCollector(child.stderr, this.maxOutputBytes, cbErr);
|
|
969
1004
|
let timedOut = false;
|
|
970
1005
|
let settled = false;
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
}, timeoutS * 1000);
|
|
976
|
-
|
|
977
|
-
// Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it; never
|
|
978
|
-
// hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
|
|
1006
|
+
// Both timers are armed further down, once `finish` exists. They are declared here, as `let`,
|
|
1007
|
+
// so the closures that clear them can never reference a binding in its temporal dead zone
|
|
1008
|
+
// whatever the callback ordering turns out to be.
|
|
1009
|
+
let timer = null;
|
|
979
1010
|
let hardTimer = null;
|
|
1011
|
+
|
|
980
1012
|
const finish = (code, signal) => {
|
|
981
1013
|
if (settled) return;
|
|
982
1014
|
settled = true;
|
|
983
|
-
clearTimeout(timer);
|
|
1015
|
+
if (timer) clearTimeout(timer);
|
|
984
1016
|
if (hardTimer) clearTimeout(hardTimer);
|
|
985
1017
|
// kern has read the file by the time it exits; leaving it behind would accrete one per call
|
|
986
1018
|
// in a persistent `workspace`.
|
|
@@ -1008,20 +1040,22 @@ class Sandbox {
|
|
|
1008
1040
|
child.on("close", (code, signal) => {
|
|
1009
1041
|
finish(code, signal);
|
|
1010
1042
|
});
|
|
1011
|
-
//
|
|
1043
|
+
// Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it;
|
|
1044
|
+
// never hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
|
|
1012
1045
|
const armHardNet = () => {
|
|
1013
1046
|
if (hardTimer) return;
|
|
1014
1047
|
hardTimer = setTimeout(() => finish(EXIT_SIGKILL, "SIGKILL"), 10000);
|
|
1015
1048
|
};
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1049
|
+
|
|
1050
|
+
timer = setTimeout(() => {
|
|
1051
|
+
timedOut = true;
|
|
1052
|
+
this._teardown(child, name, childEnv);
|
|
1053
|
+
// Armed HERE, at the one place that decides to kill. It used to be noticed instead by a
|
|
1054
|
+
// 250 ms setInterval that `finish` never cleared, so after every call that interval kept the
|
|
1055
|
+
// event loop alive until its own next tick: measured 224 to 232 ms of dead time between a
|
|
1056
|
+
// call resolving and the process being able to exit, against 19 to 27 ms of real work.
|
|
1057
|
+
armHardNet();
|
|
1058
|
+
}, timeoutS * 1000);
|
|
1025
1059
|
});
|
|
1026
1060
|
}
|
|
1027
1061
|
|
|
@@ -1726,7 +1760,24 @@ class Kernel {
|
|
|
1726
1760
|
} catch {
|
|
1727
1761
|
/* ignore */
|
|
1728
1762
|
}
|
|
1729
|
-
|
|
1763
|
+
// Wait for the exit EVENT, capped at 150 ms, rather than sleeping 150 ms unconditionally: that
|
|
1764
|
+
// fixed sleep cost 152 ms on every close of a persistent kernel (measured) for a box that
|
|
1765
|
+
// exits in a few. A child that is already gone has emitted `exit` and will not emit it again,
|
|
1766
|
+
// so that case is tested directly instead of waited on.
|
|
1767
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1768
|
+
await new Promise((resolve) => {
|
|
1769
|
+
let t = null;
|
|
1770
|
+
const onExit = () => {
|
|
1771
|
+
if (t !== null) clearTimeout(t);
|
|
1772
|
+
resolve();
|
|
1773
|
+
};
|
|
1774
|
+
t = setTimeout(() => {
|
|
1775
|
+
child.removeListener("exit", onExit);
|
|
1776
|
+
resolve();
|
|
1777
|
+
}, 150);
|
|
1778
|
+
child.once("exit", onExit);
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1730
1781
|
this._kill();
|
|
1731
1782
|
} else {
|
|
1732
1783
|
this._kill();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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",
|