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.
Files changed (3) hide show
  1. package/README.md +15 -4
  2. package/index.js +71 -1
  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.
@@ -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 mode is on the roadmap. See the project's
218
- [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
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.13";
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.13",
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",