kern-sandbox 0.2.7 → 0.2.8

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 +8 -0
  2. package/index.js +48 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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.8";
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
@@ -1227,6 +1227,15 @@ class Sandbox {
1227
1227
  constructor(opts = {}) {
1228
1228
  this.image = opts.image ?? DEFAULT_IMAGE;
1229
1229
  this.setup = opts.setup ?? null;
1230
+ // `setup` is ONE SHELL COMMAND, and a list of package names is the natural first guess. Without this
1231
+ // it reaches `_runSetup` and dies as `TypeError: cmd.trim is not a function`, an internal error where
1232
+ // a sentence belongs (the Python binding had the same edge, as an AttributeError).
1233
+ if (this.setup !== null && typeof this.setup !== "string")
1234
+ throw new SandboxError(
1235
+ `setup must be a shell command STRING, not ${Array.isArray(this.setup) ? "an array" : typeof this.setup}: ` +
1236
+ 'write setup: "pip install pandas matplotlib" for packages, or any one line the setup box ' +
1237
+ "should run (it runs once, with the network on)",
1238
+ );
1230
1239
  this.workspace = opts.workspace ?? null;
1231
1240
  this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
1232
1241
  this.cpus = opts.cpus ?? null;
@@ -1875,7 +1884,21 @@ class Sandbox {
1875
1884
  _wsPath(rel) {
1876
1885
  // Lexical containment: normalize `..`/`.`, require it stays under the workspace base. Symlinks in
1877
1886
  // the final component are neutralized by O_NOFOLLOW on the actual open below.
1887
+ //
1888
+ // AN ABSOLUTE PATH IS NOT A WORKSPACE PATH, and it takes its own check because `path.join` KEEPS the
1889
+ // base for an absolute second argument while Python's `os.path.join` DROPS it. The same three lines
1890
+ // therefore refused in the Python binding and silently resolved `<workspace>/etc/passwd` here:
1891
+ // MEASURED, `readFile("/etc/passwd")` returned a decoy the box had planted at that relative path and
1892
+ // `writeFile("/etc/passwd")` wrote into it. The boundary held either way; the ANSWER was a different
1893
+ // file's contents than the one asked for, which is worse than an error. A caller who passes a host
1894
+ // path is asking for a host file, so the honest answer is a refusal.
1878
1895
  const base = this._ws;
1896
+ if (path.isAbsolute(rel))
1897
+ throw new SandboxError(
1898
+ `path escapes the workspace: ${JSON.stringify(rel)} is absolute, and these calls take a path ` +
1899
+ "RELATIVE to the workspace. Nothing outside it is readable or writable through them, and an " +
1900
+ "absolute path is NOT reinterpreted as a workspace one",
1901
+ );
1879
1902
  const full = path.normalize(path.join(base, rel));
1880
1903
  if (full !== base && !full.startsWith(base + path.sep))
1881
1904
  throw new SandboxError(`path escapes the workspace: ${JSON.stringify(rel)}`);
@@ -1933,7 +1956,7 @@ class Sandbox {
1933
1956
  0o644,
1934
1957
  );
1935
1958
  } catch (e) {
1936
- throw new SandboxError(`cannot write ${JSON.stringify(rel)}: ${e.message}`);
1959
+ throw pathRefusal("write", rel, e);
1937
1960
  }
1938
1961
  try {
1939
1962
  // The file the box left at this name has to be a REGULAR file before we write into it: writing
@@ -2006,7 +2029,7 @@ class Sandbox {
2006
2029
  // a denial of service the workspace hands out for free, and O_NOFOLLOW does not touch it.
2007
2030
  fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
2008
2031
  } catch (e) {
2009
- throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
2032
+ throw pathRefusal("read", rel, e);
2010
2033
  }
2011
2034
  try {
2012
2035
  this._assertFdInWorkspace(fd, rel); // race-free backstop: a swapped-in parent symlink is caught here
@@ -2399,6 +2422,28 @@ function kernelDriver(outCap, resCap, hello = false) {
2399
2422
  * sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
2400
2423
  * captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
2401
2424
  * stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
2425
+ /** The message for a host-side open the workspace boundary refused.
2426
+ *
2427
+ * ELOOP here is not a filesystem oddity, it is the boundary working: the final component is opened
2428
+ * O_NOFOLLOW, so a symlink the BOX planted at a path the host is about to touch fails instead of
2429
+ * redirecting. Raw, it reads `ELOOP: too many symbolic links encountered`, which sends a reader looking
2430
+ * for a broken link chain when what happened is an attempt to reach a host file. */
2431
+ function pathRefusal(verb, rel, e) {
2432
+ if (e && e.code === "ELOOP")
2433
+ return new SandboxError(
2434
+ `refusing to ${verb} ${JSON.stringify(rel)}: a component of that path is a SYMLINK. Host-side ` +
2435
+ "reads and writes never follow one (O_NOFOLLOW), because a link planted inside the workspace is " +
2436
+ "how a box reaches a host file it was not given (the kernel reports this as ELOOP). Remove it, " +
2437
+ "or name the file you meant",
2438
+ );
2439
+ if (e && e.code === "ENXIO")
2440
+ return new SandboxError(
2441
+ `refusing to ${verb} ${JSON.stringify(rel)}: it is a FIFO with no reader. Opening one for writing ` +
2442
+ "would block until the box chose to read, so the open is non-blocking and fails instead",
2443
+ );
2444
+ return new SandboxError(`cannot ${verb} ${JSON.stringify(rel)}: ${e && e.message ? e.message : e}`);
2445
+ }
2446
+
2402
2447
  class Kernel {
2403
2448
  constructor(sbx, timeoutS) {
2404
2449
  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.8",
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",