kern-sandbox 0.1.11 → 0.1.12

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 +4 -2
  2. package/index.js +70 -9
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # kern-sandbox (Node.js / TypeScript)
2
2
 
3
- **[kern](https://github.com/getkern/kern)** is a fast, rootless, daemonless Linux sandbox runtime: a real,
4
- kernel-enforced box that starts in **~2.3 ms**, from one **~1.8 MB** binary, with no daemon. **kern-sandbox**
3
+ **[kern](https://github.com/getkern/kern)** is a fast, rootless sandbox and virtual resource
4
+ runtime for any workload, including untrusted and AI-generated code: a real, kernel-enforced box
5
+ that starts in **3.6 ms** from an OCI image, out of one **~1.8 MB** binary, with no daemon.
6
+ **kern-sandbox**
5
7
  is its Node / TypeScript binding: run untrusted or agent-generated code in a fresh, isolated box, from Node.
6
8
 
7
9
  On npm: [`npm install kern-sandbox`](https://www.npmjs.com/package/kern-sandbox). For Python, the same
package/index.js CHANGED
@@ -36,12 +36,19 @@ 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.11";
39
+ const VERSION = "0.1.12";
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
43
43
  const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
44
44
  const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
45
+ // One file per CALL, `.kern-env.<box-name>`. A single fixed name made concurrent calls on the same
46
+ // Sandbox fight over one path: one call `unlink`ed the file while kern was still starting for
47
+ // another and had not read it yet, and that box died with
48
+ // error: sandbox: cannot read --env-file '...': No such file or directory
49
+ // Measured at 30 concurrent runCode calls: 2 failed that way, and one file was left behind.
50
+ // The `O_EXCL|O_NOFOLLOW` create is a security property and is unchanged; only the NAME is per-call.
51
+ const ENV_SEP = ".";
45
52
  const INLINE_CODE_MAX = 128 * 1024; // above this, pass code via a file instead of argv (ARG_MAX guard)
46
53
  // Cap the results file the (untrusted) box writes before the binding reads it into host RAM: a malicious
47
54
  // cell could stream a multi-GB `.res` to disk (past its own memory cap) and OOM the host.
@@ -660,7 +667,9 @@ function tarCollect(dir, base, skip, out) {
660
667
  if (st.isDirectory()) tarCollect(abs, base, skip, out);
661
668
  else if (st.isFile()) {
662
669
  const rel = path.relative(base, abs);
663
- if (rel === skip) continue; // our private --env-file, not user state
670
+ // `skip` names OUR env file. Since it is now one per call, match the `<skip>.` prefix too, so a
671
+ // file left behind by a process that died mid-call cannot end up inside a user's snapshot.
672
+ if (rel === skip || rel.startsWith(skip + ENV_SEP)) continue;
664
673
  tarWriteFile(out, rel.split(path.sep).join("/"), fs.readFileSync(abs));
665
674
  }
666
675
  }
@@ -835,6 +844,29 @@ class Sandbox {
835
844
 
836
845
  // -- the box invocation --------------------------------------------------------------------------
837
846
 
847
+ /** Host path of the private --env-file for the box called `name`, inside the workspace. */
848
+ _envPath(name) {
849
+ return path.join(this._ws, `${ENV_FILE}${ENV_SEP}${name}`);
850
+ }
851
+
852
+ /**
853
+ * Is `rel` one of OUR env files rather than user state? Exact-match on the legacy name plus the
854
+ * `.kern-env.` prefix, never a bare startsWith: a user file called `.kern-environment` is theirs
855
+ * and must still show up in `files` and in a snapshot.
856
+ */
857
+ static _isEnvFile(rel) {
858
+ return rel === ENV_FILE || rel.startsWith(ENV_FILE + ENV_SEP);
859
+ }
860
+
861
+ /** Remove this call's env file. Every exit path calls it; a missing file is the desired end state. */
862
+ _removeEnvFile(name) {
863
+ try {
864
+ fs.unlinkSync(this._envPath(name));
865
+ } catch {
866
+ /* ENOENT is fine: no env was passed, or it is already gone */
867
+ }
868
+ }
869
+
838
870
  _baseArgv(name, { network, timeoutS, isSetup = false }) {
839
871
  const argv = [
840
872
  this._kern, "box", name, "--image", this.image, "--ro",
@@ -867,8 +899,13 @@ class Sandbox {
867
899
  if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
868
900
  // Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
869
901
  // `ps` to any local user for the box's lifetime; a credential in env= would leak).
870
- if (Object.keys(mergedEnv).length > 0) {
871
- const envPath = path.join(this._ws, ENV_FILE);
902
+ // `_ws` is set by open(); before that it is "". The public API is gated, but the unit tests call
903
+ // `_baseArgv` directly to inspect the argv, and with an empty workspace `path.join` yielded a
904
+ // RELATIVE path, so the env file was written into the current directory. Same as the Python side:
905
+ // it had been landing in the repository, hidden by a `.gitignore` line that stopped matching when
906
+ // the name became per-call. No workspace means nowhere to put it.
907
+ if (Object.keys(mergedEnv).length > 0 && this._ws) {
908
+ const envPath = this._envPath(name);
872
909
  const lines = [];
873
910
  for (const [k, v] of Object.entries(mergedEnv)) {
874
911
  const val = String(v);
@@ -923,6 +960,7 @@ class Sandbox {
923
960
  stdio: ["ignore", "pipe", "pipe"],
924
961
  });
925
962
  } catch (e) {
963
+ this._removeEnvFile(name);
926
964
  return reject(new SandboxError(`could not spawn the box: ${e.message}`));
927
965
  }
928
966
 
@@ -944,6 +982,9 @@ class Sandbox {
944
982
  settled = true;
945
983
  clearTimeout(timer);
946
984
  if (hardTimer) clearTimeout(hardTimer);
985
+ // kern has read the file by the time it exits; leaving it behind would accrete one per call
986
+ // in a persistent `workspace`.
987
+ this._removeEnvFile(name);
947
988
  const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
948
989
  const stdout = out.buffer().toString("utf8");
949
990
  const stderr = err.buffer().toString("utf8");
@@ -959,6 +1000,7 @@ class Sandbox {
959
1000
  };
960
1001
 
961
1002
  child.on("error", (e) => {
1003
+ this._removeEnvFile(name);
962
1004
  if (e && e.code === "ENOENT")
963
1005
  return reject(new SandboxError(`could not execute kern (${argv[0]}): not found`));
964
1006
  return reject(new SandboxError(`could not execute kern: ${e.message}`));
@@ -1200,7 +1242,7 @@ class Sandbox {
1200
1242
  snapshot(dest) {
1201
1243
  this._requireEntered();
1202
1244
  this._requireSnapshotOptIn();
1203
- fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE));
1245
+ fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE)); // prefix-excluded inside tarPack
1204
1246
  }
1205
1247
 
1206
1248
  /** Extract a snapshot (from snapshot()) into the workspace, SAFELY. Every member is vetted first:
@@ -1316,7 +1358,7 @@ class Sandbox {
1316
1358
  }
1317
1359
  if (!st.isFile()) continue; // excludes symlinks and non-regular files
1318
1360
  const rel = path.relative(base, fp);
1319
- if (rel === ENV_FILE) continue; // our private host-side env file, not a user artifact
1361
+ if (Sandbox._isEnvFile(rel)) continue; // our private host-side env file, not a user artifact
1320
1362
  out[rel] = [Math.round(st.mtimeMs * 1e6), st.size];
1321
1363
  }
1322
1364
  }
@@ -1595,6 +1637,15 @@ class Kernel {
1595
1637
  const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
1596
1638
  return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
1597
1639
  }
1640
+ return this._resultFromReply(reply, started);
1641
+ }
1642
+
1643
+ /** Turn one kernel reply into an `ExecutionResult`.
1644
+ *
1645
+ * Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
1646
+ * is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
1647
+ * attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
1648
+ _resultFromReply(reply, started) {
1598
1649
  let obj;
1599
1650
  try {
1600
1651
  obj = JSON.parse(reply);
@@ -1603,15 +1654,25 @@ class Kernel {
1603
1654
  }
1604
1655
  if (!obj || typeof obj !== "object")
1605
1656
  return this._teardownResult("killed", "the kernel sent a non-object reply", started);
1657
+ // `rc` is the ONE field whose absence cannot be defaulted. `success` is
1658
+ // `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
1659
+ // what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
1660
+ // declare its own failed run successful by omitting the field or sending a string. An unusable
1661
+ // status is not a status: it is a protocol violation by the in-box runner, which always emits
1662
+ // `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
1663
+ // boolean, a float and a numeric string, which is what it is here for.
1664
+ if (!Number.isInteger(obj.rc))
1665
+ return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
1666
+ // The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
1667
+ // failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
1668
+ // sent a number.
1606
1669
  const results = Array.isArray(obj.results)
1607
1670
  ? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
1608
1671
  : [];
1609
- // obj is UNTRUSTED (box-controlled JSON): coerce scalars so a non-string stdout / non-int rc can't
1610
- // crash a caller doing r.stdout.trim() or arithmetic on r.exitCode.
1611
1672
  return new ExecutionResult({
1612
1673
  stdout: typeof obj.stdout === "string" ? obj.stdout : "",
1613
1674
  stderr: typeof obj.stderr === "string" ? obj.stderr : "",
1614
- exitCode: Number.isInteger(obj.rc) ? obj.rc : 0,
1675
+ exitCode: obj.rc,
1615
1676
  durationMs: Date.now() - started,
1616
1677
  fault: null,
1617
1678
  files: [],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.11",
4
- "description": "kern is a fast, rootless, daemonless Linux sandbox runtime; kern-sandbox is its Node/TypeScript binding. Run untrusted or agent-generated code (Python/JS/Bash) in a real, kernel-enforced box, ~2 ms, no cloud, no account, no VM.",
3
+ "version": "0.1.12",
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",
7
7
  "kern",