kern-sandbox 0.2.11 → 0.2.13

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 +5 -2
  2. package/index.js +71 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -156,8 +156,11 @@ Every relaxing option says so in its name or docs:
156
156
  - **output bounded**: `maxOutputBytes` (64 MiB each) so a flooding box cannot exhaust host RAM.
157
157
  - **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
158
158
  line, so a credential in `env` does not leak into `ps`.
159
- - **mounts refused**: sensitive host sources (`/`, `/etc`, `/root`, `/proc`, `/sys`, `/dev`, the docker
160
- socket, `$HOME`) and escaping targets are refused even when asked.
159
+ - **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
160
+ `$HOME`, the docker socket), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
161
+ `.kube`, `.docker`, `.azure`, `.password-store`, `.netrc`, `.git-credentials`, `.pypirc`, `.npmrc`),
162
+ **kern's own state** (`$XDG_RUNTIME_DIR/kern`, the image cache, the config dir: the sandbox's control
163
+ plane), and escaping targets.
161
164
  - **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes, open the final component
162
165
  `O_NOFOLLOW` so a symlink the box plants cannot redirect host I/O, and refuse anything that is not a
163
166
  REGULAR file (see the notes for the FIFO that made a read hang).
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.11";
39
+ const VERSION = "0.2.13";
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
@@ -1995,18 +1995,61 @@ class Sandbox {
1995
1995
 
1996
1996
  /** Write `data` (Buffer|string) to `path` (workspace-relative) - host-direct, so the box sees it next
1997
1997
  * run. The final component is opened O_NOFOLLOW: a symlink the box planted can't redirect the write. */
1998
+ /** Open the PARENT of a workspace-relative path as a directory fd, one component at a time, each with
1999
+ * `O_NOFOLLOW`, and return that fd. The caller then opens the leaf THROUGH it via
2000
+ * `/proc/self/fd/<dirfd>/<leaf>`, which the kernel resolves from the pinned descriptor rather than from
2001
+ * the path string, so no component can be swapped between the check and the open.
2002
+ *
2003
+ * WHY, and it is the one asymmetry a security review found in this file: `readFile` has a race-free
2004
+ * backstop (`_assertFdInWorkspace` on the open fd, line ~2094) and `writeFile` had only the `lstat`
2005
+ * pre-check in `_ensureParentDirs` plus `O_NOFOLLOW` on the LEAF. `O_NOFOLLOW` does not touch
2006
+ * intermediate components, so a box that swaps `mid` from a directory to a symlink in the window
2007
+ * between the descent and the open makes the host create (and `O_TRUNC`) a file wherever that link
2008
+ * points. Measured here: 426 074 attempts against a concurrent swapper produced 0 escapes and 3
2009
+ * refusals, so the window is real and microseconds wide - too narrow to demonstrate cheaply and too
2010
+ * cheap to close to leave open. Python's binding never had it: it descends with `openat`.
2011
+ *
2012
+ * The fd is the caller's to close. */
2013
+ _openParentDirNofollow(rel) {
2014
+ const base = fs.realpathSync(this._ws);
2015
+ const full = this._wsPath(rel);
2016
+ const relDir = path.relative(base, path.dirname(full));
2017
+ let dirFd = fs.openSync(base, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
2018
+ if (relDir === "" || relDir === ".") return dirFd;
2019
+ for (const part of relDir.split(path.sep)) {
2020
+ if (!part || part === ".") continue;
2021
+ let next;
2022
+ try {
2023
+ next = fs.openSync(
2024
+ `/proc/self/fd/${dirFd}/${part}`,
2025
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
2026
+ );
2027
+ } catch (e) {
2028
+ fs.closeSync(dirFd);
2029
+ throw pathRefusal("write", rel, e);
2030
+ }
2031
+ fs.closeSync(dirFd);
2032
+ dirFd = next;
2033
+ }
2034
+ return dirFd;
2035
+ }
2036
+
1998
2037
  async writeFile(rel, data) {
1999
2038
  this._requireEntered();
2000
2039
  const full = this._wsPath(rel);
2001
- this._ensureParentDirs(full); // symlink-safe descent, NOT mkdir -p (which follows a planted symlink)
2040
+ this._ensureParentDirs(full); // creates missing dirs, symlink-safe, NOT mkdir -p (which follows one)
2002
2041
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
2042
+ // THE LEAF IS OPENED THROUGH A PINNED PARENT FD, not by path: see `_openParentDirNofollow`. The
2043
+ // pre-check above still runs, because it is what CREATES the missing directories; what it cannot do
2044
+ // is stay true between its own lstat and this open.
2045
+ const dirFd = this._openParentDirNofollow(rel);
2003
2046
  let fd;
2004
2047
  try {
2005
2048
  // O_NONBLOCK for the same reason as readFile, and the write side is the WORSE of the two: opening
2006
2049
  // a FIFO for writing blocks until a reader appears, and with the flag it fails outright (ENXIO)
2007
2050
  // instead. Either way the call returns to the caller rather than parking there.
2008
2051
  fd = fs.openSync(
2009
- full,
2052
+ `/proc/self/fd/${dirFd}/${path.basename(full)}`,
2010
2053
  fs.constants.O_WRONLY |
2011
2054
  fs.constants.O_CREAT |
2012
2055
  fs.constants.O_TRUNC |
@@ -2016,7 +2059,11 @@ class Sandbox {
2016
2059
  );
2017
2060
  } catch (e) {
2018
2061
  throw pathRefusal("write", rel, e);
2062
+ } finally {
2063
+ fs.closeSync(dirFd);
2019
2064
  }
2065
+ // AND THE BACKSTOP THE READ PATH ALREADY HAD: where did the descriptor actually land?
2066
+ this._assertFdInWorkspace(fd, rel);
2020
2067
  try {
2021
2068
  // The file the box left at this name has to be a REGULAR file before we write into it: writing
2022
2069
  // into a device node or a socket the box planted is host I/O it chose the target of.
@@ -2477,10 +2524,6 @@ function kernelDriver(outCap, resCap, hello = false) {
2477
2524
  .replaceAll("__KERN_HELLO__", hello ? "1" : "0");
2478
2525
  }
2479
2526
 
2480
- /** A warm, persistent Python interpreter living in one long-lived box (see `Sandbox.kernel`). `runCode`
2481
- * sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
2482
- * captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
2483
- * stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
2484
2527
  /** The message for a host-side open the workspace boundary refused.
2485
2528
  *
2486
2529
  * ELOOP here is not a filesystem oddity, it is the boundary working: the final component is opened
@@ -2502,7 +2545,10 @@ function pathRefusal(verb, rel, e) {
2502
2545
  );
2503
2546
  return new SandboxError(`cannot ${verb} ${JSON.stringify(rel)}: ${e && e.message ? e.message : e}`);
2504
2547
  }
2505
-
2548
+ /** A warm, persistent Python interpreter living in one long-lived box (see `Sandbox.kernel`). `runCode`
2549
+ * sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
2550
+ * captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
2551
+ * stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
2506
2552
  class Kernel {
2507
2553
  constructor(sbx, timeoutS) {
2508
2554
  this._sbx = sbx;
@@ -2766,12 +2812,28 @@ class Kernel {
2766
2812
  "the kernel box was killed, and its memory cap was not enforced here (no cgroup delegation), so no memory limit was in force to attribute it to",
2767
2813
  rc,
2768
2814
  ];
2769
- if (this._sbx.memoryMb !== null)
2815
+ if (this._sbx.memoryMb !== null) {
2816
+ // A BINARY THAT DOES NOT REPORT THE SIGNAL CANNOT HAVE THIS SENTENCE PUT IN ITS MOUTH. MEASURED on
2817
+ // the released 0.9.32, which is what `install.sh` serves today: it writes two of the four teardown
2818
+ // bytes, so `workloadSignal` is null, and a cell that SEGFAULTED and a cell whose syscall the
2819
+ // seccomp filter refused both landed here and were told "an external kill", which is false for
2820
+ // both. The verdict cannot improve without the byte; the sentence can say so.
2821
+ if (kernWrotePayload && workloadSignal === null)
2822
+ return [
2823
+ "killed",
2824
+ "the kernel box was killed and the kernel reported no OOM against its memory cap. THIS kern " +
2825
+ "does not report which signal ended the box (it writes 2 of the 4 teardown bytes), so an " +
2826
+ "external kill, a crash in your own code and a syscall the sandbox refused are " +
2827
+ "indistinguishable from here: a newer kern separates them, and until then read the box's " +
2828
+ "stderr before concluding it was killed from outside",
2829
+ rc,
2830
+ ];
2770
2831
  return [
2771
2832
  "killed",
2772
2833
  "the kernel box was killed and the kernel reported no OOM against its memory cap: an external kill (`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory",
2773
2834
  rc,
2774
2835
  ];
2836
+ }
2775
2837
  return ["killed", "the kernel box exited", rc];
2776
2838
  }
2777
2839
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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",