kern-sandbox 0.1.11 → 0.1.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 -3
  2. package/index.js +106 -29
  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.4 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
@@ -175,7 +177,7 @@ can still WRITE an artifact to the workspace and `readFile` it if you prefer.
175
177
  **Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
176
178
  CPython boot (~12 ms) every call. When you run many cells that share state (a REPL, a notebook, an
177
179
  agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
178
- In-memory state persists across cells and the per-cell cost drops from ~16 ms to **sub-millisecond**
180
+ In-memory state persists across cells and the per-cell cost drops from ~14 ms to **sub-millisecond**
179
181
  (~300x). Same rich `results` capture as `runCode`.
180
182
 
181
183
  ```js
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.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
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
 
@@ -930,20 +968,20 @@ class Sandbox {
930
968
  const err = cappedCollector(child.stderr, this.maxOutputBytes, cbErr);
931
969
  let timedOut = false;
932
970
  let settled = false;
933
-
934
- const timer = setTimeout(() => {
935
- timedOut = true;
936
- this._teardown(child, name, childEnv);
937
- }, timeoutS * 1000);
938
-
939
- // Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it; never
940
- // hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
971
+ // Both timers are armed further down, once `finish` exists. They are declared here, as `let`,
972
+ // so the closures that clear them can never reference a binding in its temporal dead zone
973
+ // whatever the callback ordering turns out to be.
974
+ let timer = null;
941
975
  let hardTimer = null;
976
+
942
977
  const finish = (code, signal) => {
943
978
  if (settled) return;
944
979
  settled = true;
945
- clearTimeout(timer);
980
+ if (timer) clearTimeout(timer);
946
981
  if (hardTimer) clearTimeout(hardTimer);
982
+ // kern has read the file by the time it exits; leaving it behind would accrete one per call
983
+ // in a persistent `workspace`.
984
+ this._removeEnvFile(name);
947
985
  const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
948
986
  const stdout = out.buffer().toString("utf8");
949
987
  const stderr = err.buffer().toString("utf8");
@@ -959,6 +997,7 @@ class Sandbox {
959
997
  };
960
998
 
961
999
  child.on("error", (e) => {
1000
+ this._removeEnvFile(name);
962
1001
  if (e && e.code === "ENOENT")
963
1002
  return reject(new SandboxError(`could not execute kern (${argv[0]}): not found`));
964
1003
  return reject(new SandboxError(`could not execute kern: ${e.message}`));
@@ -966,20 +1005,22 @@ class Sandbox {
966
1005
  child.on("close", (code, signal) => {
967
1006
  finish(code, signal);
968
1007
  });
969
- // arm the hard net only once we've decided to kill (teardown sets timedOut)
1008
+ // Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it;
1009
+ // never hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
970
1010
  const armHardNet = () => {
971
1011
  if (hardTimer) return;
972
1012
  hardTimer = setTimeout(() => finish(EXIT_SIGKILL, "SIGKILL"), 10000);
973
1013
  };
974
- // re-check shortly after the deadline in case teardown fired
975
- const watch = setInterval(() => {
976
- if (settled) {
977
- clearInterval(watch);
978
- } else if (timedOut) {
979
- clearInterval(watch);
980
- armHardNet();
981
- }
982
- }, 250);
1014
+
1015
+ timer = setTimeout(() => {
1016
+ timedOut = true;
1017
+ this._teardown(child, name, childEnv);
1018
+ // Armed HERE, at the one place that decides to kill. It used to be noticed instead by a
1019
+ // 250 ms setInterval that `finish` never cleared, so after every call that interval kept the
1020
+ // event loop alive until its own next tick: measured 224 to 232 ms of dead time between a
1021
+ // call resolving and the process being able to exit, against 19 to 27 ms of real work.
1022
+ armHardNet();
1023
+ }, timeoutS * 1000);
983
1024
  });
984
1025
  }
985
1026
 
@@ -1200,7 +1241,7 @@ class Sandbox {
1200
1241
  snapshot(dest) {
1201
1242
  this._requireEntered();
1202
1243
  this._requireSnapshotOptIn();
1203
- fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE));
1244
+ fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE)); // prefix-excluded inside tarPack
1204
1245
  }
1205
1246
 
1206
1247
  /** Extract a snapshot (from snapshot()) into the workspace, SAFELY. Every member is vetted first:
@@ -1316,7 +1357,7 @@ class Sandbox {
1316
1357
  }
1317
1358
  if (!st.isFile()) continue; // excludes symlinks and non-regular files
1318
1359
  const rel = path.relative(base, fp);
1319
- if (rel === ENV_FILE) continue; // our private host-side env file, not a user artifact
1360
+ if (Sandbox._isEnvFile(rel)) continue; // our private host-side env file, not a user artifact
1320
1361
  out[rel] = [Math.round(st.mtimeMs * 1e6), st.size];
1321
1362
  }
1322
1363
  }
@@ -1595,6 +1636,15 @@ class Kernel {
1595
1636
  const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
1596
1637
  return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
1597
1638
  }
1639
+ return this._resultFromReply(reply, started);
1640
+ }
1641
+
1642
+ /** Turn one kernel reply into an `ExecutionResult`.
1643
+ *
1644
+ * Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
1645
+ * is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
1646
+ * attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
1647
+ _resultFromReply(reply, started) {
1598
1648
  let obj;
1599
1649
  try {
1600
1650
  obj = JSON.parse(reply);
@@ -1603,15 +1653,25 @@ class Kernel {
1603
1653
  }
1604
1654
  if (!obj || typeof obj !== "object")
1605
1655
  return this._teardownResult("killed", "the kernel sent a non-object reply", started);
1656
+ // `rc` is the ONE field whose absence cannot be defaulted. `success` is
1657
+ // `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
1658
+ // what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
1659
+ // declare its own failed run successful by omitting the field or sending a string. An unusable
1660
+ // status is not a status: it is a protocol violation by the in-box runner, which always emits
1661
+ // `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
1662
+ // boolean, a float and a numeric string, which is what it is here for.
1663
+ if (!Number.isInteger(obj.rc))
1664
+ return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
1665
+ // The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
1666
+ // failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
1667
+ // sent a number.
1606
1668
  const results = Array.isArray(obj.results)
1607
1669
  ? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
1608
1670
  : [];
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
1671
  return new ExecutionResult({
1612
1672
  stdout: typeof obj.stdout === "string" ? obj.stdout : "",
1613
1673
  stderr: typeof obj.stderr === "string" ? obj.stderr : "",
1614
- exitCode: Number.isInteger(obj.rc) ? obj.rc : 0,
1674
+ exitCode: obj.rc,
1615
1675
  durationMs: Date.now() - started,
1616
1676
  fault: null,
1617
1677
  files: [],
@@ -1665,7 +1725,24 @@ class Kernel {
1665
1725
  } catch {
1666
1726
  /* ignore */
1667
1727
  }
1668
- await new Promise((r) => setTimeout(r, 150));
1728
+ // Wait for the exit EVENT, capped at 150 ms, rather than sleeping 150 ms unconditionally: that
1729
+ // fixed sleep cost 152 ms on every close of a persistent kernel (measured) for a box that
1730
+ // exits in a few. A child that is already gone has emitted `exit` and will not emit it again,
1731
+ // so that case is tested directly instead of waited on.
1732
+ if (child.exitCode === null && child.signalCode === null) {
1733
+ await new Promise((resolve) => {
1734
+ let t = null;
1735
+ const onExit = () => {
1736
+ if (t !== null) clearTimeout(t);
1737
+ resolve();
1738
+ };
1739
+ t = setTimeout(() => {
1740
+ child.removeListener("exit", onExit);
1741
+ resolve();
1742
+ }, 150);
1743
+ child.once("exit", onExit);
1744
+ });
1745
+ }
1669
1746
  this._kill();
1670
1747
  } else {
1671
1748
  this._kill();
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.13",
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",