kern-sandbox 0.2.7 → 0.2.9

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 +9 -1
  2. package/index.js +75 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -129,7 +129,7 @@ A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it i
129
129
  | `oom` | the kernel's OOM killer took the box against its own memory cap. Read from a descriptor the code in the box cannot write, so it is an observation and not a guess from the exit code |
130
130
  | `killed` | SIGKILL with **no** OOM reported: an external kill (`kern stop`, a signal, the host out of memory), or a cap that did not bind here, which the message names |
131
131
  | `exec_failed` | the box started, the command did not exist inside it. `{language:"node"}` on an image with no `node` is the ordinary way there; the message names the binary AND the image |
132
- | `startup_failed` | your `timeoutS` fired while kern was still BUILDING the box, so the code never ran. A longer timeout does not help: a bind source on a dead NFS export does this |
132
+ | `startup_failed` | the box never ran, and kern said why in `stderr`. Two shapes: your `timeoutS` fired while kern was still BUILDING the box (run it again: a fast second call was a cold image read), or kern refused to build it at all (an image that cannot be pulled, a mount it will not make) |
133
133
 
134
134
  ```js
135
135
  const r = await kern.runCode("while True: pass", { timeoutS: 5 });
@@ -216,6 +216,14 @@ cannot exfiltrate elsewhere. Mutually exclusive with `network: true`. The `setup
216
216
  network to install dependencies; the allowlist governs the run phase, which is the one executing code
217
217
  you did not read.
218
218
 
219
+ It is a **route-level** boundary, not proxy variables a program can ignore. Measured inside the box: a
220
+ raw socket to an IP returns `ENETUNREACH`, DNS does not resolve, and a request to a domain outside the
221
+ list is refused by the tunnel with `403`, while the same socket under `network: true` connects. The other
222
+ edge of that: a client which does not speak to an HTTP proxy has no path out at all, so a Postgres, MySQL
223
+ or Redis connection under `egressAllow` cannot resolve its host. For a database, the setting today is
224
+ `network: true`.
225
+
226
+
219
227
  `kernel()` returns a `Kernel`, and a refused mount throws `MountRefused` rather than the generic
220
228
  `SandboxError`, so a caller can tell "this sandbox will not do that" from "the sandbox broke".
221
229
  `DEFAULT_TMPFS_MB` and `version` are exported for callers that assert on them.
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.7";
39
+ const VERSION = "0.2.9";
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
@@ -446,6 +446,26 @@ const REFUSED_MOUNT_SOURCES = new Set([
446
446
  "/run/docker.sock",
447
447
  ]);
448
448
 
449
+ /** Credential directories, refused as a COMPONENT anywhere in the source. The set above is absolute
450
+ * paths, so it refused `$HOME` and accepted `$HOME/.ssh`: MEASURED, a box mounted with `~/.ssh` listed
451
+ * `id_ed25519` and `authorized_keys`. Refusing the parent and allowing its most sensitive child is the
452
+ * wrong way round, and it is the scenario a prompt-injected agent is steered into ("read ~/.aws"). These
453
+ * match by NAME because they live under a per-user home. No escape hatch, same as `/etc`: a job that
454
+ * needs one credential should be given that one file in the workspace. */
455
+ const REFUSED_MOUNT_COMPONENTS = new Set([
456
+ ".ssh",
457
+ ".aws",
458
+ ".gnupg",
459
+ ".kube",
460
+ ".docker",
461
+ ".azure",
462
+ ".password-store",
463
+ ".netrc",
464
+ ".git-credentials",
465
+ ".pypirc",
466
+ ".npmrc",
467
+ ]);
468
+
449
469
  /** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, `kern` not installed, or the box
450
470
  * FAILED TO START (kern exits 125 - a mount refused at runtime, an unmappable `--user`, a seccomp or
451
471
  * AppArmor setup error). A box that never started ran no user code, so it rejects rather than resolve a
@@ -837,6 +857,13 @@ function validateMount(source, target) {
837
857
  `refusing to mount the sensitive host path ${JSON.stringify(real)} into a sandbox ` +
838
858
  "(this would defeat the isolation)",
839
859
  );
860
+ for (const part of real.split(path.sep))
861
+ if (REFUSED_MOUNT_COMPONENTS.has(part))
862
+ throw new MountRefused(
863
+ `refusing to mount ${JSON.stringify(real)}: ${JSON.stringify(part)} holds credentials, and code ` +
864
+ "in the box would read them. If the job needs one secret, write THAT FILE into the workspace " +
865
+ "(sbx.writeFile) or mount a directory that holds only it",
866
+ );
840
867
  return [real, target];
841
868
  }
842
869
 
@@ -1227,6 +1254,15 @@ class Sandbox {
1227
1254
  constructor(opts = {}) {
1228
1255
  this.image = opts.image ?? DEFAULT_IMAGE;
1229
1256
  this.setup = opts.setup ?? null;
1257
+ // `setup` is ONE SHELL COMMAND, and a list of package names is the natural first guess. Without this
1258
+ // it reaches `_runSetup` and dies as `TypeError: cmd.trim is not a function`, an internal error where
1259
+ // a sentence belongs (the Python binding had the same edge, as an AttributeError).
1260
+ if (this.setup !== null && typeof this.setup !== "string")
1261
+ throw new SandboxError(
1262
+ `setup must be a shell command STRING, not ${Array.isArray(this.setup) ? "an array" : typeof this.setup}: ` +
1263
+ 'write setup: "pip install pandas matplotlib" for packages, or any one line the setup box ' +
1264
+ "should run (it runs once, with the network on)",
1265
+ );
1230
1266
  this.workspace = opts.workspace ?? null;
1231
1267
  this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
1232
1268
  this.cpus = opts.cpus ?? null;
@@ -1875,7 +1911,21 @@ class Sandbox {
1875
1911
  _wsPath(rel) {
1876
1912
  // Lexical containment: normalize `..`/`.`, require it stays under the workspace base. Symlinks in
1877
1913
  // the final component are neutralized by O_NOFOLLOW on the actual open below.
1914
+ //
1915
+ // AN ABSOLUTE PATH IS NOT A WORKSPACE PATH, and it takes its own check because `path.join` KEEPS the
1916
+ // base for an absolute second argument while Python's `os.path.join` DROPS it. The same three lines
1917
+ // therefore refused in the Python binding and silently resolved `<workspace>/etc/passwd` here:
1918
+ // MEASURED, `readFile("/etc/passwd")` returned a decoy the box had planted at that relative path and
1919
+ // `writeFile("/etc/passwd")` wrote into it. The boundary held either way; the ANSWER was a different
1920
+ // file's contents than the one asked for, which is worse than an error. A caller who passes a host
1921
+ // path is asking for a host file, so the honest answer is a refusal.
1878
1922
  const base = this._ws;
1923
+ if (path.isAbsolute(rel))
1924
+ throw new SandboxError(
1925
+ `path escapes the workspace: ${JSON.stringify(rel)} is absolute, and these calls take a path ` +
1926
+ "RELATIVE to the workspace. Nothing outside it is readable or writable through them, and an " +
1927
+ "absolute path is NOT reinterpreted as a workspace one",
1928
+ );
1879
1929
  const full = path.normalize(path.join(base, rel));
1880
1930
  if (full !== base && !full.startsWith(base + path.sep))
1881
1931
  throw new SandboxError(`path escapes the workspace: ${JSON.stringify(rel)}`);
@@ -1933,7 +1983,7 @@ class Sandbox {
1933
1983
  0o644,
1934
1984
  );
1935
1985
  } catch (e) {
1936
- throw new SandboxError(`cannot write ${JSON.stringify(rel)}: ${e.message}`);
1986
+ throw pathRefusal("write", rel, e);
1937
1987
  }
1938
1988
  try {
1939
1989
  // The file the box left at this name has to be a REGULAR file before we write into it: writing
@@ -2006,7 +2056,7 @@ class Sandbox {
2006
2056
  // a denial of service the workspace hands out for free, and O_NOFOLLOW does not touch it.
2007
2057
  fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
2008
2058
  } catch (e) {
2009
- throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
2059
+ throw pathRefusal("read", rel, e);
2010
2060
  }
2011
2061
  try {
2012
2062
  this._assertFdInWorkspace(fd, rel); // race-free backstop: a swapped-in parent symlink is caught here
@@ -2399,6 +2449,28 @@ function kernelDriver(outCap, resCap, hello = false) {
2399
2449
  * sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
2400
2450
  * captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
2401
2451
  * stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
2452
+ /** The message for a host-side open the workspace boundary refused.
2453
+ *
2454
+ * ELOOP here is not a filesystem oddity, it is the boundary working: the final component is opened
2455
+ * O_NOFOLLOW, so a symlink the BOX planted at a path the host is about to touch fails instead of
2456
+ * redirecting. Raw, it reads `ELOOP: too many symbolic links encountered`, which sends a reader looking
2457
+ * for a broken link chain when what happened is an attempt to reach a host file. */
2458
+ function pathRefusal(verb, rel, e) {
2459
+ if (e && e.code === "ELOOP")
2460
+ return new SandboxError(
2461
+ `refusing to ${verb} ${JSON.stringify(rel)}: a component of that path is a SYMLINK. Host-side ` +
2462
+ "reads and writes never follow one (O_NOFOLLOW), because a link planted inside the workspace is " +
2463
+ "how a box reaches a host file it was not given (the kernel reports this as ELOOP). Remove it, " +
2464
+ "or name the file you meant",
2465
+ );
2466
+ if (e && e.code === "ENXIO")
2467
+ return new SandboxError(
2468
+ `refusing to ${verb} ${JSON.stringify(rel)}: it is a FIFO with no reader. Opening one for writing ` +
2469
+ "would block until the box chose to read, so the open is non-blocking and fails instead",
2470
+ );
2471
+ return new SandboxError(`cannot ${verb} ${JSON.stringify(rel)}: ${e && e.message ? e.message : e}`);
2472
+ }
2473
+
2402
2474
  class Kernel {
2403
2475
  constructor(sbx, timeoutS) {
2404
2476
  this._sbx = sbx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",