kern-sandbox 0.2.27 → 0.2.29

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 +6 -3
  2. package/index.js +65 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -21,8 +21,9 @@ unenforceable cap FATAL: the box refuses to start rather than run uncapped. It a
21
21
  other refusal does, as `fault.type === "startup_failed"` on the result, NOT as an exception from the
22
22
  constructor, so a caller that only catches exceptions will walk past it.
23
23
 
24
- Dependency-free: it shells out to the `kern` binary and does not re-implement isolation in
25
- JavaScript.
24
+ The runtime this drives, its tests and the other bindings are one repository:
25
+ **[github.com/getkern/kern](https://github.com/getkern/kern)**. Dependency-free: it shells out to
26
+ the `kern` binary and does not re-implement isolation in JavaScript.
26
27
 
27
28
  **Your loop reads a field, not a stack trace.** A timeout, an OOM-kill, a blocked syscall or a missing
28
29
  interpreter each arrive as a typed `fault` on the result, beside stdout and the exit code, so the
@@ -176,7 +177,9 @@ Every relaxing option says so in its name or docs:
176
177
  line, so a credential in `env` does not leak into `ps`.
177
178
  - **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
178
179
  `$HOME`, the docker socket), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
179
- `.kube`, `.docker`, `.azure`, `.password-store`, `.netrc`, `.git-credentials`, `.pypirc`, `.npmrc`),
180
+ `.kube`, `.docker`, `.azure`, `.oci`, `.terraform.d`, `.password-store`, `.netrc`, `.git-credentials`,
181
+ `.pypirc`, `.npmrc`, `.databrickscfg`, `.boto`, `.s3cfg`, `.rclone.conf`, and under `.config`:
182
+ `gcloud`, `gh`, `doctl`, `rclone`),
180
183
  **kern's own state** (`$XDG_RUNTIME_DIR/kern`, the image cache, the config dir, the data dir that
181
184
  holds every named volume: the sandbox's control plane), and escaping targets.
182
185
  - **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes, open the final component
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.2.27";
39
+ const VERSION = "0.2.29";
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
@@ -493,6 +493,22 @@ function kernStateDirs() {
493
493
  * wrong way round, and it is the scenario a prompt-injected agent is steered into ("read ~/.aws"). These
494
494
  * match by NAME because they live under a per-user home. No escape hatch, same as `/etc`: a job that
495
495
  * needs one credential should be given that one file in the workspace. */
496
+ //
497
+ // THE LIST WAS SHORT OF THE PROMISE ABOVE IT, and the asymmetry is what gave it away: AWS refused,
498
+ // Azure refused, GCP accepted. MEASURED against the published SDK with each directory CREATED first,
499
+ // because a refusal that is really "source does not exist" is a skip wearing a pass - that is how
500
+ // `~/.config/gcloud` read as covered on a host that has no gcloud.
501
+ //
502
+ // `~/.config/<tool>` NEEDS THE PARENT, which is why there is a second set below: `gcloud` and `gh`
503
+ // are not dotfiles, and refusing a bare `gh` component anywhere would refuse `~/projects/gh/src` - a
504
+ // guard that fires on ordinary work gets switched off, and then it guards nothing.
505
+ //
506
+ // DELIBERATELY NOT ADDED: `.cargo`, `.m2`, `.gem`. Each holds ONE credential file next to a package
507
+ // cache people legitimately mount, so refusing the directory would break a real use and push callers
508
+ // off the guard. The residual gap is named rather than papered over.
509
+ //
510
+ // KEPT IDENTICAL TO THE PYTHON BINDING on purpose: two spellings of one rule drift, and a caller who
511
+ // moved between the two SDKs would meet a different boundary in each.
496
512
  const REFUSED_MOUNT_COMPONENTS = new Set([
497
513
  ".ssh",
498
514
  ".aws",
@@ -505,6 +521,20 @@ const REFUSED_MOUNT_COMPONENTS = new Set([
505
521
  ".git-credentials",
506
522
  ".pypirc",
507
523
  ".npmrc",
524
+ ".oci",
525
+ ".terraform.d",
526
+ ".databrickscfg",
527
+ ".boto",
528
+ ".s3cfg",
529
+ ".rclone.conf",
530
+ ]);
531
+
532
+ // `parent/child` pairs, refused when they appear CONSECUTIVELY in the source.
533
+ const REFUSED_MOUNT_PAIRS = new Set([
534
+ ".config/gcloud",
535
+ ".config/gh",
536
+ ".config/doctl",
537
+ ".config/rclone",
508
538
  ]);
509
539
 
510
540
  /** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, `kern` not installed, or the box
@@ -908,13 +938,23 @@ function validateMount(source, target) {
908
938
  "docker socket is refused",
909
939
  );
910
940
  }
911
- for (const part of real.split(path.sep))
912
- if (REFUSED_MOUNT_COMPONENTS.has(part))
913
- throw new MountRefused(
914
- `refusing to mount ${JSON.stringify(real)}: ${JSON.stringify(part)} holds credentials, and code ` +
915
- "in the box would read them. If the job needs one secret, write THAT FILE into the workspace " +
916
- "(sbx.writeFile) or mount a directory that holds only it",
917
- );
941
+ const parts = real.split(path.sep);
942
+ let hit = parts.find((p) => REFUSED_MOUNT_COMPONENTS.has(p));
943
+ if (hit === undefined)
944
+ // The `parent/child` form, consecutive so `~/.config/gh` is refused and `~/gh` is not.
945
+ for (let i = 0; i + 1 < parts.length; i++) {
946
+ const pair = `${parts[i]}/${parts[i + 1]}`;
947
+ if (REFUSED_MOUNT_PAIRS.has(pair)) {
948
+ hit = pair;
949
+ break;
950
+ }
951
+ }
952
+ if (hit !== undefined)
953
+ throw new MountRefused(
954
+ `refusing to mount ${JSON.stringify(real)}: ${JSON.stringify(hit)} holds credentials, and code ` +
955
+ "in the box would read them. If the job needs one secret, write THAT FILE into the workspace " +
956
+ "(sbx.writeFile) or mount a directory that holds only it",
957
+ );
918
958
  return [real, target];
919
959
  }
920
960
 
@@ -1449,6 +1489,22 @@ class Sandbox {
1449
1489
  if (!Array.isArray(this.capDrop))
1450
1490
  throw new SandboxError("capDrop must be an array of capability names");
1451
1491
  this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
1492
+ // SKIP THE UID RANGE EXACTLY WHEN THE CAPABILITY IT SERVES IS BEING DROPPED ANYWAY, which is the
1493
+ // default and costs a quarter of a cold box. `kern box --image` maps a sub-uid RANGE by default
1494
+ // (so an image that degrades privilege in its entrypoint works), and mapping it forks two SETUID
1495
+ // HELPERS. MEASURED: `parent:idmap` 22 us single-uid against ~1048 us ranged, and the whole box
1496
+ // 3234 against 4298 us on this class's argv - paired, core-pinned, -1083 us (25%).
1497
+ //
1498
+ // IT BUYS THIS SANDBOX NOTHING when `ALL` is dropped, measured rather than argued: `setuid(1000)`
1499
+ // inside a cell is refused either way under `--cap-drop ALL` (EPERM with the range, EINVAL
1500
+ // without). WITHOUT it the range does work, so this is conditional: `capDrop: []` is a documented
1501
+ // choice and keeps both the capability and the range.
1502
+ //
1503
+ // KEPT IDENTICAL TO THE PYTHON BINDING, including the condition: two spellings of one rule drift,
1504
+ // and a caller who moved between the SDKs would meet a different box shape in each.
1505
+ this._singleUid = this.capDrop.some(
1506
+ (c) => String(c).toUpperCase().replace(/^CAP_/, "") === "ALL",
1507
+ );
1452
1508
  this._profileArgs = (this.profiles || []).map(validateProfile);
1453
1509
  this._egressAllow = (this.egressAllow || []).map(validateDomain);
1454
1510
  if (this.apparmor !== null) validateApparmor(this.apparmor);
@@ -1593,6 +1649,7 @@ class Sandbox {
1593
1649
  }
1594
1650
  // kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
1595
1651
  argv.push(...this._capDropArgs);
1652
+ if (this._singleUid) argv.push("--no-uid-range");
1596
1653
  argv.push("--timeout", String(Math.floor(timeoutS) + 5));
1597
1654
  if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
1598
1655
  if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "kern is a fast, rootless sandbox and virtual resource runtime for any workload, including untrusted and LLM-generated code; kern-sandbox is its Node/TypeScript binding. Run untrusted or agent-generated code (Python/JS/Bash) in a real, kernel-enforced box, one per call, with no cloud, no account and no VM.",
5
5
  "keywords": [
6
6
  "sandbox",