kern-sandbox 0.1.14 → 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 +12 -5
- package/index.js +37 -2
- 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.
|
|
@@ -135,7 +136,7 @@ new Sandbox({
|
|
|
135
136
|
timeoutS, // default 30, MANDATORY per-call deadline
|
|
136
137
|
network, // default false (RELAXES ISOLATION)
|
|
137
138
|
capDrop, // default ["ALL"]: capabilities dropped from every box. kern always drops
|
|
138
|
-
//
|
|
139
|
+
// 14 dangerous ones; this drops the rest, which were held over the box's own
|
|
139
140
|
// user namespace. Pass [] to keep them (needed only if the workload binds a
|
|
140
141
|
// port below 1024 INSIDE the box).
|
|
141
142
|
mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
|
|
@@ -143,6 +144,11 @@ new Sandbox({
|
|
|
143
144
|
env, // { KEY: "value" }
|
|
144
145
|
maxOutputBytes, // default 64 MiB
|
|
145
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.
|
|
146
152
|
depsReadonly, // default false
|
|
147
153
|
trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
|
|
148
154
|
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
@@ -218,8 +224,9 @@ clear error otherwise). The Python binding uses the stdlib `tarfile` and has no
|
|
|
218
224
|
kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code (CI, dev, edge, your
|
|
219
225
|
agents' code). Its seccomp filter is a **denylist**: right for semi-trusted agent code, **not** a hard
|
|
220
226
|
boundary against deliberately hostile multi-tenant code. For that, reach for a microVM (Firecracker /
|
|
221
|
-
Kata) or gVisor. A deny-by-default allowlist
|
|
222
|
-
|
|
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).
|
|
223
230
|
|
|
224
231
|
## License
|
|
225
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
|
|
@@ -578,6 +578,24 @@ function validateCap(name) {
|
|
|
578
578
|
return name;
|
|
579
579
|
}
|
|
580
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
|
+
|
|
581
599
|
/** Map a Node close event {code, signal} to a unix-style rc (128 + signum for a signal). */
|
|
582
600
|
function toRc(code, signal) {
|
|
583
601
|
if (typeof code === "number") return code;
|
|
@@ -780,9 +798,22 @@ class Sandbox {
|
|
|
780
798
|
this.onStdout = opts.onStdout ?? null;
|
|
781
799
|
this.onStderr = opts.onStderr ?? null;
|
|
782
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;
|
|
783
814
|
this.depsReadonly = opts.depsReadonly ?? false;
|
|
784
815
|
// 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
|
|
816
|
+
// The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
|
|
786
817
|
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
787
818
|
// running code nobody has read. Defence in depth rather than the boundary itself, and measured to
|
|
788
819
|
// cost nothing. It is NOT behaviour-free: a workload binding a port below 1024 INSIDE the box
|
|
@@ -826,6 +857,7 @@ class Sandbox {
|
|
|
826
857
|
this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
|
|
827
858
|
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
828
859
|
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
860
|
+
if (this.apparmor !== null) validateApparmor(this.apparmor);
|
|
829
861
|
if (this._egressAllow.length && this.network)
|
|
830
862
|
throw new SandboxError(
|
|
831
863
|
"egressAllow and network:true are mutually exclusive: egressAllow gives a restricted domain " +
|
|
@@ -920,6 +952,9 @@ class Sandbox {
|
|
|
920
952
|
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
921
953
|
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
|
922
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);
|
|
923
958
|
// Network mode: egressAllow (a domain allowlist via an isolated netns + kern's filtering proxy)
|
|
924
959
|
// governs the untrusted runCode/run boxes; the setup box keeps the full network it needs to install
|
|
925
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",
|