kern-sandbox 0.1.39 → 0.1.41

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 +28 -97
  2. package/index.js +58 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,9 +12,9 @@ allowlist, and a wall-clock deadline the binding applies from **outside** the bo
12
12
  cannot outlive it. Dependency-free: it shells out to the `kern` binary and does not re-implement
13
13
  isolation in JavaScript.
14
14
 
15
- **The failure comes back as data, not as an exception.** A timeout, an OOM-kill, a blocked syscall or
16
- a missing interpreter is a typed `fault` on the result, beside stdout and the exit code, so an agent
17
- loop reads a field instead of parsing a stack trace to learn that the sandbox ended the run.
15
+ **Your loop reads a field, not a stack trace.** A timeout, an OOM-kill, a blocked syscall or a missing
16
+ interpreter each arrive as a typed `fault` on the result, beside stdout and the exit code, so the
17
+ agent branches on a value instead of parsing text to work out who ended the run.
18
18
 
19
19
  On npm: [`npm install kern-sandbox`](https://www.npmjs.com/package/kern-sandbox). Python gets the same
20
20
  package on PyPI: [`kern-sandbox`](https://pypi.org/project/kern-sandbox/), which also ships an **MCP
@@ -109,6 +109,8 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
109
109
  | field | meaning |
110
110
  |---|---|
111
111
  | `stdout`, `stderr` | captured output (each capped at `maxOutputBytes`) |
112
+ | `codeStderr` | `stderr` with kern's own `note:`/`warning:` lines removed: what the code wrote. Feed THIS to a model |
113
+ | `runtimeNotes` | the complement: the lines kern wrote about itself. `stderr` still holds both, in order |
112
114
  | `exitCode` | the process exit code |
113
115
  | `durationMs` | wall-clock duration of the call, in ms |
114
116
  | `success` | `true` iff `exitCode === 0` **and** no sandbox fault |
@@ -118,6 +120,13 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
118
120
  | `truncated` | output hit the cap and overflow was discarded |
119
121
 
120
122
  A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it is a normal result.
123
+
124
+ `stderr` is one stream shared by kern and your code, so a note about overlayfs or an undelegated
125
+ cgroup arrives interleaved with the program's own output. That is right for a human reading a
126
+ terminal and wrong for anything that puts `stderr` into a prompt, where it spends context on the
127
+ runtime's housekeeping and reads like an error the code produced. `codeStderr` is the same string
128
+ without those lines, and nothing is hidden: `runtimeNotes` holds exactly what was taken out. The
129
+ LangChain tool and the MCP server already use it.
121
130
  `fault` is only set when the **sandbox** acted:
122
131
 
123
132
  | `fault.type` | when |
@@ -202,52 +211,10 @@ new Sandbox({
202
211
  });
203
212
  ```
204
213
 
205
- **Writable paths: `/workspace`, `/tmp` and `/dev/shm`.** The box root is read-only, so `/tmp` is a
206
- 64 MiB tmpfs this binding mounts for you. Without it a write naming `/tmp` fails with `EROFS` and
207
- temp-file helpers fall back to the current directory, quietly putting scratch into your persistent
208
- workspace where `listFiles` then reports it. The bytes are charged to the box's own memory cgroup, so
209
- filling `/tmp` OOM-kills the box and never fills the host disk. Resize with `tmpfs: { "/tmp": "512m" }`,
210
- remove with `tmpfs: {}`, or bind your own directory at `/tmp` through `mounts` and the default steps
211
- aside (a `:ro` bind included, which leaves `/tmp` read-only: your call, not an accident). **The unit is
212
- required and the target may not contain a `:`.** kern's CLI takes both spellings and means the
213
- opposite of what you do: a bare `"64"` is 64 BYTES, `"0"` is UNLIMITED, and `["/scratch:9g"]` mounts
214
- `/scratch` at 9 GiB rather than a directory by that name. All three measured, all three refused here. A size larger than `memoryMb` is refused for the same family
215
- of reason: `df` would report it to a program that preflights. The binding's own default is clamped to
216
- half the cap instead.
217
-
218
- **`memoryMb` bounds the cgroup, not the workload's usable memory.** The cap is shared with
219
- memory-backed filesystems in the same box, and `/dev/shm` is one of them with **no size at all** (the
220
- kernel's tmpfs default, half of host RAM, so it scales with the machine and not with your config).
221
- Measured: 200 MiB written there under `memoryMb: 128` OOM-kills the box whatever `/tmp` is set to.
222
- `tmpfs: { "/dev/shm": ... }` is refused by kern; `mounts` at the same target IS accepted and stacks over
223
- kern's own mount; measured through it, `multiprocessing.shared_memory` and POSIX semaphores still
224
- work. Two costs: a plain directory is unbounded on DISK instead of in RAM, so bounding it means
225
- binding a host directory that is itself a sized tmpfs, and it has no tmpfs lifetime, so what the box
226
- writes to `/dev/shm` is still on the host after the box dies.
227
-
228
- **Scratch does not survive a call.** Each `runCode` is a fresh box, so `/tmp` is fresh too while the
229
- workspace persists. Put anything a later call must find in the workspace. The `setup` box is the exception: an install needs unbounded scratch, so the default is not
230
- applied there (an explicit `tmpfs` still is).
231
-
232
- **Toolchains in the box** need two writable places, and the error names neither. Go reports `failed to
233
- initialize build cache at /root/.cache`, which says nothing about `HOME`; npm renders a failed
234
- `mkdir /root/.npm` as `Invalid response body while trying to fetch https://registry.npmjs.org/...`,
235
- which reads as a network fault and is not one. Measured on `node:22`: neither -> exit 2, `HOME` alone
236
- with a read-only `/tmp` -> still exit 2, both -> exit 0. Pass both:
237
-
238
- ```js
239
- new Sandbox({
240
- image: "golang:1.23-alpine",
241
- env: { HOME: "/workspace" }, // npm's ~/.npm, Go's ~/.cache, Rust's CARGO_HOME, .NET's NuGet
242
- tmpfs: { "/tmp": "512m" }, // scratch; 64 MiB fits a small install, a real one needs more
243
- });
244
- ```
245
-
246
- `runCode`/`run` also take `timeoutS`/`onStdout`/`onStderr` as **per-call** options that override the
247
- session defaults for that one call. A `vcpu:` profile can carry `cpus`+`memory`; `memoryMb`/`cpus` are
248
- explicit flags that **override** a profile's values (and the `memoryMb` default `512` shadows a profile's
249
- `memory`, so pass `memoryMb: null` to let the profile apply). The **MCP server** (`kern-mcp`, for Claude
250
- Desktop / Cursor) ships in the Python package `kern-sandbox` (`pip install kern-sandbox`).
214
+ **The sharp edges are in [SANDBOX-NOTES.md](https://github.com/getkern/kern/blob/main/bindings/node/SANDBOX-NOTES.md):**
215
+ the writable paths and why `/tmp` is a tmpfs, `memoryMb` bounding the cgroup rather than usable
216
+ memory, scratch that does not survive a call, and the two writable places a toolchain needs before
217
+ `npm install` stops reporting a network error that is not one. Each is a measured surprise.
251
218
 
252
219
  ## Egress: the setting between no network and the host's
253
220
 
@@ -308,60 +275,24 @@ package manager imports `globSync` from `node:fs`, which landed in 22.
308
275
 
309
276
  ## Charts, rich results, live output, and checkpoints
310
277
 
311
- **Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
312
- Jupyter cell it captures rich, mime-typed values into `result.results` (a list of `Result`) with
313
- **no Jupyter kernel**: the value of the code's **last bare expression**, every **`display(obj)`** call,
314
- and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.png`/`.jpeg` (Buffer),
315
- `.html`, `.svg`, `.markdown`, `.json`, `.text`.
278
+ `runCode` captures mime-typed values into `result.results` the way a notebook cell does: the **last
279
+ bare expression**, every **`display(obj)`**, and **every open matplotlib figure automatically**, with
280
+ no `savefig`. Accessors: `.png`, `.jpeg`, `.html`, `.svg`, `.markdown`, `.json`, `.text`.
316
281
 
317
282
  ```js
318
- await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) => {
319
- let r = await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
320
- "import matplotlib.pyplot as plt; plt.plot([1,4,9])");
321
- const png = r.results.map((x) => x.png).find(Boolean) ?? null; // figure Buffer, auto-captured
322
-
323
- r = await sbx.runCode("import pandas as pd; pd.DataFrame({'a':[1,2]})");
324
- r.results[0].html; // the DataFrame as an HTML table (also .text)
283
+ await withSandbox({ setup: "pip install pandas matplotlib" }, async (sbx) => {
284
+ await sbx.writeFile("data.csv", "a,b\n1,2\n3,4\n");
285
+ const r = await sbx.runCode("import pandas as pd; pd.read_csv('data.csv').describe()");
286
+ r.results[0].html; // the DataFrame as an HTML table
325
287
  });
326
288
  ```
327
289
 
328
- Capture never touches `stdout`/`stderr`/`exitCode`; a statement returning `None` yields no result. You
329
- can still WRITE an artifact to the workspace and `readFile` it if you prefer.
330
-
331
- **Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
332
- CPython boot (~12 ms) every call. When you run many cells that share state (a REPL, a notebook, an
333
- agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
334
- In-memory state persists across cells and the per-cell cost drops from ~14 ms to **sub-millisecond**
335
- (~300x). Same rich `results` capture as `runCode`.
336
-
337
- ```js
338
- await kern.withSandbox(async (sbx) => {
339
- const k = await sbx.kernel();
340
- try {
341
- await k.runCode("import numpy as np; a = np.arange(1_000_000)"); // imports paid once
342
- const r = await k.runCode("a.sum()"); // 'a' is still here; ~sub-ms
343
- console.log(r.results[0].text); // 499999500000
344
- } finally {
345
- await k.close(); // tears the box down
346
- }
347
- });
348
- ```
290
+ Capture never touches `stdout`, `stderr` or `exitCode`. Pass `onStdout` / `onStderr` to stream output
291
+ as it arrives (best-effort: a slow callback drops chunks rather than stalling the box).
349
292
 
350
- The trade vs `runCode`: cells in a kernel share one process and one box, so it is call-fast but not
351
- call-isolated (still network-off and resource-capped; a fresh session or kernel is clean). An uncaught
352
- error is confined (`exitCode` 1, traceback on `stderr`, the kernel keeps serving); a per-cell `timeoutS`
353
- tears the kernel down (a running cell cannot be interrupted), after which it refuses further cells.
354
-
355
- **Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
356
- best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
357
- (the full capped output is always in `result.stdout`).
358
-
359
- **Checkpoints.** `sbx.snapshot(dest)` writes a portable `.tar.gz` of the workspace (a FILESYSTEM
360
- checkpoint, not memory); `sbx.restore(src)` extracts it back, refusing absolute / `..` / symlink
361
- members. Interoperable with `tar` and the Python binding (both write plain USTAR, so a workspace path
362
- must be under 100 bytes). The Node path uses a hand-rolled tar reader,
363
- so while it is new it is **opt-in**: set `KERN_SANDBOX_SNAPSHOT=1` to enable it (it fails closed with a
364
- clear error otherwise). The Python binding uses the stdlib `tarfile` and has no such gate.
293
+ `snapshot(dest)` and `restore(src)` write a portable `.tar.gz` checkpoint of the **workspace**;
294
+ `restore` refuses absolute, `..` and symlink members. Nothing in `/tmp` is on it, because a tmpfs is
295
+ on no layer.
365
296
 
366
297
  ## Honest threat model
367
298
 
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.1.39";
39
+ const VERSION = "0.1.41";
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
@@ -510,6 +510,47 @@ class ExecutionResult {
510
510
  get success() {
511
511
  return this.exitCode === 0 && this.fault === null;
512
512
  }
513
+ /** `stderr` with kern's own `note:`/`warning:`/posture lines removed: what the code actually wrote.
514
+ *
515
+ * This is what belongs in a model's context. A workload CAN forge one of kern's prefixes, and the
516
+ * consequence is its own line moving to {@link runtimeNotes}: the trick removes its text from this
517
+ * field, it cannot inject text into it.
518
+ *
519
+ * The guarantee is LINE-ALIGNED, not absolute: a workload that leaves a line unterminated and is
520
+ * then interleaved with a `kern: warning:` on the shared stderr produces one line starting with the
521
+ * workload's text, which no prefix matches, so kern's warning lands here framed by bytes the
522
+ * workload chose. Racy rather than reliable, and in the less harmful direction, but real. Mirrors
523
+ * `ExecutionResult.code_stderr` in Python. */
524
+ get codeStderr() {
525
+ return this._splitStderr()[0];
526
+ }
527
+ /** Partition `stderr` ONCE into [what the code wrote, the lines kern wrote].
528
+ *
529
+ * One pass and one cache, mirroring `_split_stderr` in Python. The two public halves are a single
530
+ * partition, so computing them separately left two filters that had to agree by inspection rather
531
+ * than by construction; and each was O(n) on every read, measured at 13.9 ms on a 200k-line stderr
532
+ * in the Python binding before this. Keyed on the string it partitioned, so reassigning `stderr`
533
+ * recomputes rather than serving a stale answer. Non-enumerable, so it stays out of JSON and out of
534
+ * anything that walks the result's own keys. */
535
+ _splitStderr() {
536
+ const raw = String(this.stderr || "");
537
+ if (this._stderrSplit && this._stderrSplit[0] === raw) {
538
+ return [this._stderrSplit[1], this._stderrSplit[2]];
539
+ }
540
+ const kept = [];
541
+ const notes = [];
542
+ for (const line of raw.split("\n")) (isKernDiagnostic(line) ? notes : kept).push(line);
543
+ const joined = kept.join("\n");
544
+ Object.defineProperty(this, "_stderrSplit", {
545
+ value: [raw, joined, notes], writable: true, enumerable: false, configurable: true,
546
+ });
547
+ return [joined, notes];
548
+ }
549
+ /** The lines on `stderr` that KERN wrote, the complement of {@link codeStderr}. Reported rather
550
+ * than removed: `stderr` still holds every byte in its original order. */
551
+ get runtimeNotes() {
552
+ return this._splitStderr()[1].slice();
553
+ }
513
554
  }
514
555
 
515
556
  /** A sandbox event `{type, message}` for `result.fault`. NB: `startup_failed` is decided from an
@@ -821,6 +862,21 @@ function execFailureBinary(stderr) {
821
862
  return m ? { what: m[1], reason: (m[2] || "").trim() } : null;
822
863
  }
823
864
 
865
+ /** The prefixes of stderr lines KERN writes about itself, as opposed to lines the workload wrote: the
866
+ * `--security-profile` posture banner and any `warning:`/`note:` diagnostic.
867
+ *
868
+ * ONE definition, used for two purposes that must agree by construction: `codeStderr` subtracts these
869
+ * to build what a model should read, and `looksLikeStartupFailure` skips them so a benign note is not
870
+ * read as a box that failed to start. Mirrors `_KERN_DIAGNOSTICS` in the Python binding. */
871
+ const KERN_DIAGNOSTICS = ["kern: security-profile=", "kern: warning:", "kern: note:"];
872
+
873
+ function isKernDiagnostic(line) {
874
+ const s = line.replace(/^\s+/, "");
875
+ return KERN_DIAGNOSTICS.some((p) => s.startsWith(p));
876
+ }
877
+
878
+
879
+
824
880
  function looksLikeStartupFailure(stderr) {
825
881
  const markers = [
826
882
  "kern:",
@@ -833,14 +889,9 @@ function looksLikeStartupFailure(stderr) {
833
889
  "error: oci:",
834
890
  "error: image:",
835
891
  ];
836
- // kern also writes BENIGN `kern:` diagnostics that are NOT a box-start failure: the
837
- // `--security-profile` posture banner, and `warning:`/`note:` lines. They start with `kern:` too, so
838
- // without this skip a workload that merely exits non-zero WHILE one is on stderr (e.g. code run under
839
- // securityProfile: "untrusted" that hits a network error) would be mislabeled `startup_failed`.
840
- const benign = ["kern: security-profile=", "kern: warning:", "kern: note:"];
841
892
  for (const line of stderr.split("\n")) {
842
893
  const s = line.replace(/^\s+/, "");
843
- if (benign.some((b) => s.startsWith(b))) continue;
894
+ if (isKernDiagnostic(s)) continue;
844
895
  if (s.includes("sandbox setup failed") || markers.some((m) => s.startsWith(m))) return true;
845
896
  }
846
897
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
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",