kern-sandbox 0.1.14 → 0.1.16
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 +16 -6
- package/index.js +57 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,10 +33,11 @@ import { runCode, withSandbox, Sandbox } from "kern-sandbox";
|
|
|
33
33
|
npm install kern-sandbox
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
You also need the `kern` binary on `PATH` (or point `$KERN_BIN` at it).
|
|
36
|
+
You also need the `kern` binary on `PATH` (or point `$KERN_BIN` at it). Build it on Linux (no
|
|
37
|
+
prebuilt binaries are published yet):
|
|
37
38
|
|
|
38
39
|
```sh
|
|
39
|
-
|
|
40
|
+
cargo install --git https://github.com/getkern/kern getkern --locked
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2.
|
|
@@ -98,7 +99,10 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
|
|
|
98
99
|
| `timeout` | the call exceeded `timeoutS`; the binding killed the box |
|
|
99
100
|
| `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
|
|
100
101
|
| `killed` | the box was SIGKILLed, most often the cgroup OOM-killer |
|
|
101
|
-
|
|
102
|
+
|
|
103
|
+
A box that fails to **start** (kern exits 125: a mount refused at runtime, an unmappable `--user`, a
|
|
104
|
+
seccomp/AppArmor/cgroup setup error, or a pull/image error) is **thrown** as a `SandboxError`, not
|
|
105
|
+
returned as a fault, because the code never ran.
|
|
102
106
|
|
|
103
107
|
```js
|
|
104
108
|
const r = await kern.runCode("while True: pass", { timeoutS: 5 });
|
|
@@ -135,7 +139,7 @@ new Sandbox({
|
|
|
135
139
|
timeoutS, // default 30, MANDATORY per-call deadline
|
|
136
140
|
network, // default false (RELAXES ISOLATION)
|
|
137
141
|
capDrop, // default ["ALL"]: capabilities dropped from every box. kern always drops
|
|
138
|
-
//
|
|
142
|
+
// 14 dangerous ones; this drops the rest, which were held over the box's own
|
|
139
143
|
// user namespace. Pass [] to keep them (needed only if the workload binds a
|
|
140
144
|
// port below 1024 INSIDE the box).
|
|
141
145
|
mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
|
|
@@ -143,6 +147,11 @@ new Sandbox({
|
|
|
143
147
|
env, // { KEY: "value" }
|
|
144
148
|
maxOutputBytes, // default 64 MiB
|
|
145
149
|
enforceLimits, // default true; false is best-effort and NO faster (see the Python README)
|
|
150
|
+
securityProfile, // "untrusted" = seccomp allowlist + cap-drop ALL + read-only root, one opt-in bundle
|
|
151
|
+
apparmor, // a PRE-LOADED AppArmor profile the box enters on exec (Docker's --security-opt
|
|
152
|
+
// apparmor=), an LSM layer over seccomp; kern fails the box CLOSED if it isn't loaded.
|
|
153
|
+
requireLimits, // default false; true = FAIL-CLOSED (refuse to start unless caps enforced). NOT
|
|
154
|
+
// enforceLimits (that picks the cap PATH); mutually exclusive with KERN_ALLOW_UNCAPPED env.
|
|
146
155
|
depsReadonly, // default false
|
|
147
156
|
trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
|
|
148
157
|
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
@@ -218,8 +227,9 @@ clear error otherwise). The Python binding uses the stdlib `tarfile` and has no
|
|
|
218
227
|
kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code (CI, dev, edge, your
|
|
219
228
|
agents' code). Its seccomp filter is a **denylist**: right for semi-trusted agent code, **not** a hard
|
|
220
229
|
boundary against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker /
|
|
221
|
-
Kata) or gVisor. A deny-by-default allowlist
|
|
222
|
-
|
|
230
|
+
Kata) or gVisor. A deny-by-default seccomp **allowlist** ships as opt-in today: pass
|
|
231
|
+
`securityProfile: "untrusted"` (or the `KERN_SECCOMP=allowlist` env); making it the default is future
|
|
232
|
+
work. See the project's [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
|
|
223
233
|
|
|
224
234
|
## License
|
|
225
235
|
|
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.16";
|
|
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
|
|
@@ -387,8 +387,11 @@ const REFUSED_MOUNT_SOURCES = new Set([
|
|
|
387
387
|
"/run/docker.sock",
|
|
388
388
|
]);
|
|
389
389
|
|
|
390
|
-
/** A PROGRAMMER/config error, THROWN: bad argument, illegal mount,
|
|
391
|
-
*
|
|
390
|
+
/** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, `kern` not installed, or the box
|
|
391
|
+
* FAILED TO START (kern exits 125 - a mount refused at runtime, an unmappable `--user`, a seccomp or
|
|
392
|
+
* AppArmor setup error). A box that never started ran no user code, so it rejects rather than resolve a
|
|
393
|
+
* hollow result. Runtime sandbox events where the code DID run (timeout, blocked escape, OOM-kill) are
|
|
394
|
+
* NOT thrown - they are data on `result.fault`. */
|
|
392
395
|
class SandboxError extends Error {
|
|
393
396
|
constructor(message) {
|
|
394
397
|
super(message);
|
|
@@ -578,6 +581,24 @@ function validateCap(name) {
|
|
|
578
581
|
return name;
|
|
579
582
|
}
|
|
580
583
|
|
|
584
|
+
// An AppArmor profile name for `kern box --apparmor`. Same discipline as validateCap: handed to kern as
|
|
585
|
+
// its own argv element, so it must not start with a dash (-> another flag) or carry a space. Letters,
|
|
586
|
+
// digits and ._- cover ordinary profile names (docker-default, unconfined, kern-box); kern fails closed
|
|
587
|
+
// if the profile is not loaded. Namespaced names with / or : are intentionally not accepted here (use
|
|
588
|
+
// the CLI). Compared byte-for-byte with the Python binding's _APPARMOR_RE by a parity test - keep them
|
|
589
|
+
// identical and free of chars that need escaping in a regex literal (e.g. /).
|
|
590
|
+
const APPARMOR_RE = /^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,127}$/;
|
|
591
|
+
|
|
592
|
+
/** Validate an AppArmor profile name for `--apparmor` before it reaches the argv. */
|
|
593
|
+
function validateApparmor(name) {
|
|
594
|
+
if (typeof name !== "string" || !APPARMOR_RE.test(name))
|
|
595
|
+
throw new SandboxError(
|
|
596
|
+
`invalid AppArmor profile ${JSON.stringify(name)}: expected a loaded profile name like ` +
|
|
597
|
+
"'docker-default' or 'unconfined' (letters, digits and ._-, not starting with a dash)",
|
|
598
|
+
);
|
|
599
|
+
return name;
|
|
600
|
+
}
|
|
601
|
+
|
|
581
602
|
/** Map a Node close event {code, signal} to a unix-style rc (128 + signum for a signal). */
|
|
582
603
|
function toRc(code, signal) {
|
|
583
604
|
if (typeof code === "number") return code;
|
|
@@ -780,9 +801,22 @@ class Sandbox {
|
|
|
780
801
|
this.onStdout = opts.onStdout ?? null;
|
|
781
802
|
this.onStderr = opts.onStderr ?? null;
|
|
782
803
|
this.enforceLimits = opts.enforceLimits ?? true;
|
|
804
|
+
// `--require-limits`: refuse to start unless the memory/pids caps are ACTUALLY enforced (read back
|
|
805
|
+
// from the cgroup), rather than running best-effort uncapped - the fail-closed OOM / fork-bomb
|
|
806
|
+
// backstop. Distinct from `enforceLimits` (systemd-scope vs best-effort PATH); this makes an
|
|
807
|
+
// unenforceable cap fatal.
|
|
808
|
+
this.requireLimits = opts.requireLimits ?? false;
|
|
809
|
+
// `--security-profile "untrusted"`: an opt-in hardening BUNDLE (seccomp allowlist + cap-drop ALL +
|
|
810
|
+
// read-only root) for code nobody has read. The root goes read-only but a bound `mounts` path stays
|
|
811
|
+
// writable, so it composes with this SDK. null (default) leaves kern's normal posture.
|
|
812
|
+
this.securityProfile = opts.securityProfile ?? null;
|
|
813
|
+
// `--apparmor "<profile>"`: enter a pre-loaded AppArmor profile on the box's exec (Docker's
|
|
814
|
+
// `--security-opt apparmor=`), a kernel-enforced LSM layer over namespaces + seccomp. The profile
|
|
815
|
+
// must be loaded on the host; kern fails the box CLOSED if it is not. null (default) applies none.
|
|
816
|
+
this.apparmor = opts.apparmor ?? null;
|
|
783
817
|
this.depsReadonly = opts.depsReadonly ?? false;
|
|
784
818
|
// 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
|
|
819
|
+
// The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
|
|
786
820
|
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
787
821
|
// running code nobody has read. Defence in depth rather than the boundary itself, and measured to
|
|
788
822
|
// cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
|
|
@@ -826,6 +860,7 @@ class Sandbox {
|
|
|
826
860
|
this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
|
|
827
861
|
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
828
862
|
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
863
|
+
if (this.apparmor !== null) validateApparmor(this.apparmor);
|
|
829
864
|
if (this._egressAllow.length && this.network)
|
|
830
865
|
throw new SandboxError(
|
|
831
866
|
"egressAllow and network:true are mutually exclusive: egressAllow gives a restricted domain " +
|
|
@@ -920,6 +955,9 @@ class Sandbox {
|
|
|
920
955
|
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
921
956
|
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
|
922
957
|
if (this.pids !== null) argv.push("--pids-limit", String(this.pids));
|
|
958
|
+
if (this.requireLimits) argv.push("--require-limits");
|
|
959
|
+
if (this.securityProfile !== null) argv.push("--security-profile", this.securityProfile);
|
|
960
|
+
if (this.apparmor !== null) argv.push("--apparmor", this.apparmor);
|
|
923
961
|
// Network mode: egressAllow (a domain allowlist via an isolated netns + kern's filtering proxy)
|
|
924
962
|
// governs the untrusted runCode/run boxes; the setup box keeps the full network it needs to install
|
|
925
963
|
// deps. egressAllow and network are mutually exclusive (checked at construction).
|
|
@@ -1022,6 +1060,13 @@ class Sandbox {
|
|
|
1022
1060
|
const stderr = err.buffer().toString("utf8");
|
|
1023
1061
|
const rc = toRc(code, signal);
|
|
1024
1062
|
const fault = this._classify(rc, signal, stderr, timedOut, timeoutS);
|
|
1063
|
+
// A box that FAILED TO START (kern exits 125) ran no user code, so REJECT rather than resolve a
|
|
1064
|
+
// hollow ExecutionResult (empty stdout, exit 125). Mirrors the mount/config errors this SDK
|
|
1065
|
+
// already throws up front; runtime events where the code DID run (timeout, OOM, escape) stay as
|
|
1066
|
+
// data on `.fault`.
|
|
1067
|
+
if (fault && fault.type === "startup_failed") {
|
|
1068
|
+
return reject(new SandboxError(fault.message || "the box failed to start"));
|
|
1069
|
+
}
|
|
1025
1070
|
const files = before ? this._diff(before) : [];
|
|
1026
1071
|
resolve(
|
|
1027
1072
|
new ExecutionResult({
|
|
@@ -1094,6 +1139,11 @@ class Sandbox {
|
|
|
1094
1139
|
return sandboxFault("killed", "the box was killed (SIGKILL) - likely out of memory (exit 137)");
|
|
1095
1140
|
if (rc === EXIT_SIGTERM || signal === "SIGTERM")
|
|
1096
1141
|
return sandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)");
|
|
1142
|
+
// DETERMINISTIC box-not-started: kern exits 125 (Docker's convention) when it could not BUILD the
|
|
1143
|
+
// box (a mount refused at runtime, an unmappable --user, a seccomp/AppArmor/cgroup setup error), so
|
|
1144
|
+
// the workload never ran. Decided by exit code, no stderr marker needed; the caller REJECTS on it.
|
|
1145
|
+
if (rc === 125) return sandboxFault("startup_failed", stderr.trim().slice(0, 500) || "the box failed to start");
|
|
1146
|
+
// LAST resort heuristic (also catches an OLDER kern that still exits 127 for a setup failure).
|
|
1097
1147
|
if (rc !== 0 && looksLikeStartupFailure(stderr))
|
|
1098
1148
|
return sandboxFault("startup_failed", stderr.trim().slice(0, 500));
|
|
1099
1149
|
// Any other non-zero exit (incl. 139 SIGSEGV) is the USER's code failing - a normal Result.
|
|
@@ -1717,6 +1767,9 @@ class Kernel {
|
|
|
1717
1767
|
|
|
1718
1768
|
_teardownResult(type, message, started) {
|
|
1719
1769
|
this._kill();
|
|
1770
|
+
// Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
|
|
1771
|
+
// does not return a hollow result. timeout/killed stay as data on the returned result.
|
|
1772
|
+
if (type === "startup_failed") throw new SandboxError(message || "the box failed to start");
|
|
1720
1773
|
return new ExecutionResult({
|
|
1721
1774
|
stdout: "",
|
|
1722
1775
|
stderr: "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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",
|