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.
Files changed (3) hide show
  1. package/README.md +16 -6
  2. package/index.js +57 -4
  3. 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). One line on Linux:
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
- curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh
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
- | `startup_failed` | kern could not start the box (bad image, pull error, ...) |
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
- // 13 dangerous ones; this drops the rest, which were held over the box's own
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 mode is on the roadmap. See the project's
222
- [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
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.14";
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, or `kern` not installed. Runtime
391
- * sandbox events (timeout, blocked escape, OOM-kill) are NOT thrown - they are data in result.fault. */
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 13 dangerous capabilities unconditionally, but the
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.14",
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",