kern-sandbox 0.1.13 → 0.1.15
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 +15 -4
- package/index.js +71 -1
- 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.
|
|
@@ -134,11 +135,20 @@ new Sandbox({
|
|
|
134
135
|
pids, // default 256
|
|
135
136
|
timeoutS, // default 30, MANDATORY per-call deadline
|
|
136
137
|
network, // default false (RELAXES ISOLATION)
|
|
138
|
+
capDrop, // default ["ALL"]: capabilities dropped from every box. kern always drops
|
|
139
|
+
// 14 dangerous ones; this drops the rest, which were held over the box's own
|
|
140
|
+
// user namespace. Pass [] to keep them (needed only if the workload binds a
|
|
141
|
+
// port below 1024 INSIDE the box).
|
|
137
142
|
mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
|
|
138
143
|
profiles, // reusable kern.toml profiles: ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]
|
|
139
144
|
env, // { KEY: "value" }
|
|
140
145
|
maxOutputBytes, // default 64 MiB
|
|
141
146
|
enforceLimits, // default true; false is best-effort and NO faster (see the Python README)
|
|
147
|
+
securityProfile, // "untrusted" = seccomp allowlist + cap-drop ALL + read-only root, one opt-in bundle
|
|
148
|
+
apparmor, // a PRE-LOADED AppArmor profile the box enters on exec (Docker's --security-opt
|
|
149
|
+
// apparmor=), an LSM layer over seccomp; kern fails the box CLOSED if it isn't loaded.
|
|
150
|
+
requireLimits, // default false; true = FAIL-CLOSED (refuse to start unless caps enforced). NOT
|
|
151
|
+
// enforceLimits (that picks the cap PATH); mutually exclusive with KERN_ALLOW_UNCAPPED env.
|
|
142
152
|
depsReadonly, // default false
|
|
143
153
|
trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
|
|
144
154
|
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
@@ -214,8 +224,9 @@ clear error otherwise). The Python binding uses the stdlib `tarfile` and has no
|
|
|
214
224
|
kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code (CI, dev, edge, your
|
|
215
225
|
agents' code). Its seccomp filter is a **denylist**: right for semi-trusted agent code, **not** a hard
|
|
216
226
|
boundary against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker /
|
|
217
|
-
Kata) or gVisor. A deny-by-default allowlist
|
|
218
|
-
|
|
227
|
+
Kata) or gVisor. A deny-by-default seccomp **allowlist** ships as opt-in today: pass
|
|
228
|
+
`securityProfile: "untrusted"` (or the `KERN_SECCOMP=allowlist` env); making it the default is future
|
|
229
|
+
work. See the project's [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
|
|
219
230
|
|
|
220
231
|
## License
|
|
221
232
|
|
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.15";
|
|
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,34 @@ 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
|
+
|
|
581
|
+
// An AppArmor profile name for `kern box --apparmor`. Same discipline as validateCap: handed to kern as
|
|
582
|
+
// its own argv element, so it must not start with a dash (-> another flag) or carry a space. Letters,
|
|
583
|
+
// digits and ._- cover ordinary profile names (docker-default, unconfined, kern-box); kern fails closed
|
|
584
|
+
// if the profile is not loaded. Namespaced names with / or : are intentionally not accepted here (use
|
|
585
|
+
// the CLI). Compared byte-for-byte with the Python binding's _APPARMOR_RE by a parity test - keep them
|
|
586
|
+
// identical and free of chars that need escaping in a regex literal (e.g. /).
|
|
587
|
+
const APPARMOR_RE = /^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,127}$/;
|
|
588
|
+
|
|
589
|
+
/** Validate an AppArmor profile name for `--apparmor` before it reaches the argv. */
|
|
590
|
+
function validateApparmor(name) {
|
|
591
|
+
if (typeof name !== "string" || !APPARMOR_RE.test(name))
|
|
592
|
+
throw new SandboxError(
|
|
593
|
+
`invalid AppArmor profile ${JSON.stringify(name)}: expected a loaded profile name like ` +
|
|
594
|
+
"'docker-default' or 'unconfined' (letters, digits and ._-, not starting with a dash)",
|
|
595
|
+
);
|
|
596
|
+
return name;
|
|
597
|
+
}
|
|
598
|
+
|
|
565
599
|
/** Map a Node close event {code, signal} to a unix-style rc (128 + signum for a signal). */
|
|
566
600
|
function toRc(code, signal) {
|
|
567
601
|
if (typeof code === "number") return code;
|
|
@@ -764,7 +798,27 @@ class Sandbox {
|
|
|
764
798
|
this.onStdout = opts.onStdout ?? null;
|
|
765
799
|
this.onStderr = opts.onStderr ?? null;
|
|
766
800
|
this.enforceLimits = opts.enforceLimits ?? true;
|
|
801
|
+
// `--require-limits`: refuse to start unless the memory/pids caps are ACTUALLY enforced (read back
|
|
802
|
+
// from the cgroup), rather than running best-effort uncapped - the fail-closed OOM / fork-bomb
|
|
803
|
+
// backstop. Distinct from `enforceLimits` (systemd-scope vs best-effort PATH); this makes an
|
|
804
|
+
// unenforceable cap fatal.
|
|
805
|
+
this.requireLimits = opts.requireLimits ?? false;
|
|
806
|
+
// `--security-profile "untrusted"`: an opt-in hardening BUNDLE (seccomp allowlist + cap-drop ALL +
|
|
807
|
+
// read-only root) for code nobody has read. The root goes read-only but a bound `mounts` path stays
|
|
808
|
+
// writable, so it composes with this SDK. null (default) leaves kern's normal posture.
|
|
809
|
+
this.securityProfile = opts.securityProfile ?? null;
|
|
810
|
+
// `--apparmor "<profile>"`: enter a pre-loaded AppArmor profile on the box's exec (Docker's
|
|
811
|
+
// `--security-opt apparmor=`), a kernel-enforced LSM layer over namespaces + seccomp. The profile
|
|
812
|
+
// must be loaded on the host; kern fails the box CLOSED if it is not. null (default) applies none.
|
|
813
|
+
this.apparmor = opts.apparmor ?? null;
|
|
767
814
|
this.depsReadonly = opts.depsReadonly ?? false;
|
|
815
|
+
// Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
|
|
816
|
+
// The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
|
|
817
|
+
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
818
|
+
// running code nobody has read. Defence in depth rather than the boundary itself, and measured to
|
|
819
|
+
// cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
|
|
820
|
+
// needs CAP_NET_BIND_SERVICE. Pass `capDrop: []` for the previous behaviour.
|
|
821
|
+
this.capDrop = opts.capDrop ?? ["ALL"];
|
|
768
822
|
// trackFiles=true populates result.files by walking the workspace before AND after each call (O(N)
|
|
769
823
|
// in file count); a long session that accretes files slows every runCode. false = result.files [], O(1).
|
|
770
824
|
this.trackFiles = opts.trackFiles ?? true;
|
|
@@ -790,8 +844,20 @@ class Sandbox {
|
|
|
790
844
|
this._mountArgs.push("-v", ro ? `${real}:${tgt}:ro` : `${real}:${tgt}`);
|
|
791
845
|
}
|
|
792
846
|
}
|
|
847
|
+
// A bare string has a .map-less shape here, but Array.from("ALL") would yield ["A","L","L"] and
|
|
848
|
+
// three bogus flags, so refuse the string by name and say what to write instead.
|
|
849
|
+
if (typeof this.capDrop === "string")
|
|
850
|
+
throw new SandboxError(
|
|
851
|
+
`capDrop must be an array of names, not a bare string: write capDrop: [${JSON.stringify(
|
|
852
|
+
this.capDrop,
|
|
853
|
+
)}] for one, or capDrop: [] to drop none`,
|
|
854
|
+
);
|
|
855
|
+
if (!Array.isArray(this.capDrop))
|
|
856
|
+
throw new SandboxError("capDrop must be an array of capability names");
|
|
857
|
+
this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
|
|
793
858
|
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
794
859
|
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
860
|
+
if (this.apparmor !== null) validateApparmor(this.apparmor);
|
|
795
861
|
if (this._egressAllow.length && this.network)
|
|
796
862
|
throw new SandboxError(
|
|
797
863
|
"egressAllow and network:true are mutually exclusive: egressAllow gives a restricted domain " +
|
|
@@ -881,10 +947,14 @@ class Sandbox {
|
|
|
881
947
|
}
|
|
882
948
|
}
|
|
883
949
|
// kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
|
|
950
|
+
argv.push(...this._capDropArgs);
|
|
884
951
|
argv.push("--timeout", String(Math.floor(timeoutS) + 5));
|
|
885
952
|
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
886
953
|
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
|
887
954
|
if (this.pids !== null) argv.push("--pids-limit", String(this.pids));
|
|
955
|
+
if (this.requireLimits) argv.push("--require-limits");
|
|
956
|
+
if (this.securityProfile !== null) argv.push("--security-profile", this.securityProfile);
|
|
957
|
+
if (this.apparmor !== null) argv.push("--apparmor", this.apparmor);
|
|
888
958
|
// Network mode: egressAllow (a domain allowlist via an isolated netns + kern's filtering proxy)
|
|
889
959
|
// governs the untrusted runCode/run boxes; the setup box keeps the full network it needs to install
|
|
890
960
|
// deps. egressAllow and network are mutually exclusive (checked at construction).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|