kern-sandbox 0.2.3 → 0.2.5

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 -52
  2. package/index.js +1 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -28,11 +28,8 @@ const r = await kern.runCode("print(sum(range(100)))");
28
28
  console.log(r.stdout, r.success); // "4950\n" true
29
29
  ```
30
30
 
31
- TypeScript types ship in the box. `Buffer` appears in the public surface **because we typed it that
32
- way**, so a TypeScript consumer also needs `@types/node`; without it `tsc` reports `Cannot find name
33
- 'Buffer'` against this package's `.d.ts` and tells you what to install. Typing that surface as
34
- `Uint8Array` would remove the requirement (a `Buffer` is one), and that is a change to the published
35
- surface rather than a fix, so it is not in this release.
31
+ TypeScript types ship in the package. `Buffer` is in the public surface, so a TypeScript consumer also
32
+ needs `@types/node`; without it `tsc` reports `Cannot find name 'Buffer'` and says what to install.
36
33
 
37
34
  ```ts
38
35
  import { runCode, withSandbox, Sandbox } from "kern-sandbox";
@@ -120,35 +117,16 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
120
117
  | `truncated` | output hit the cap and overflow was discarded |
121
118
 
122
119
  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.
130
120
  `fault` is only set when the **sandbox** acted:
131
121
 
132
122
  | `fault.type` | when |
133
123
  |---|---|
134
124
  | `timeout` | the call exceeded `timeoutS`; the binding killed the box |
135
125
  | `escape_blocked` | a syscall was blocked by the seccomp filter (SIGSYS) |
136
- | `oom` | kern reported that the kernel's OOM killer took the box against its own memory cap: a breached `memory.max` takes the whole box, since kern sets `memory.oom.group=1`. Reported on a channel the code in the box cannot write (a third byte on kern's own descriptor), so it is an observation of the kernel's counter rather than a guess from the exit code |
137
- | `killed` | the box was SIGKILLed with **no** OOM reported against its cap: an external kill (`kern stop`, a signal, the host running out of memory), or a cap that did not bind here (no cgroup delegation, which the message names). A `memoryMb` cap being set is not, by itself, evidence that memory is what killed the box |
138
- | `exec_failed` | the box started but the command did not exist inside it. `runCode(code, {language:"node"})` on an image with no `node` is the ordinary way to reach it; the message names the binary AND the image, because the remedy is a different `language` or a different `image`. The `language` enum is a convenience, not a promise about the image: the default `python:3.12-slim` carries `python` and `bash`. A shell's own `command not found` inside your script stays an ordinary non-zero exit |
139
- | `startup_failed` | returned as data, not thrown, in one case: your `timeoutS` fired while kern was still BUILDING the box, so the code never ran. kern reports on a separate descriptor whether it reached your workload, which is what tells this from a slow cell. A host path that blocks does it: a bind source on a dead NFS export, a FUSE mount whose daemon is gone. A longer timeout does not help |
140
-
141
- **An enforced `pids` cap produces no fault, and that is deliberate.** When `pids` binds, the refused
142
- `fork` returns `EAGAIN`. Code that catches it exits 0, so the call reports `fault: null, success:
143
- true` and a contained fork bomb reads as a successful run. `EAGAIN` is an ordinary errno a program is
144
- allowed to handle, unlike a SIGKILL it cannot; labelling it a sandbox fault would misreport a process
145
- that exited cleanly. Code that does **not** catch it dies naming "Resource temporarily unavailable".
146
- The cap itself is enforced: on WSL2, `pids: 32` blocked at 29 forks while `pids: 256` let 120 through,
147
- same code and same image.
148
-
149
- A box that fails to **start** (kern exits 125: a mount refused at runtime, an unmappable `--user`, a
150
- seccomp/AppArmor/cgroup setup error, or a pull/image error) is **thrown** as a `SandboxError`, not
151
- returned as a fault, because the code never ran.
126
+ | `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 |
127
+ | `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 |
128
+ | `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 |
129
+ | `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 |
152
130
 
153
131
  ```js
154
132
  const r = await kern.runCode("while True: pass", { timeoutS: 5 });
@@ -156,6 +134,14 @@ r.success; // false
156
134
  r.fault.type; // "timeout"
157
135
  ```
158
136
 
137
+ A box that fails to **start** is **thrown** as a `SandboxError`, not returned as a fault, because the
138
+ code never ran.
139
+
140
+ `stderr` is one stream shared by kern and your code, so a note about an undelegated cgroup arrives
141
+ interleaved with the program's own output. Right for a human at a terminal, wrong for anything that puts
142
+ `stderr` into a prompt. `codeStderr` is the same string without kern's own lines, and nothing is hidden:
143
+ `runtimeNotes` holds exactly what was taken out.
144
+
159
145
  ## Safe by default
160
146
 
161
147
  Every relaxing option says so in its name or docs:
@@ -169,13 +155,9 @@ Every relaxing option says so in its name or docs:
169
155
  line, so a credential in `env` does not leak into `ps`.
170
156
  - **mounts refused**: sensitive host sources (`/`, `/etc`, `/root`, `/proc`, `/sys`, `/dev`, the docker
171
157
  socket, `$HOME`) and escaping targets are refused even when asked.
172
- - **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes and open the final component
173
- `O_NOFOLLOW`, so a symlink the box plants cannot redirect host I/O outside the workspace. They also
174
- open `O_NONBLOCK` and refuse a descriptor that is not a REGULAR file. A symlink is not the only thing
175
- a box can leave at a name: `mkfifo out.png` used to make `readFile("out.png")` wait for a writer that
176
- never comes, with no timeout, so the box chose how long the host's call took. The flag alone would be
177
- worse than the hang, because a non-blocking read of a writer-less FIFO returns zero bytes and the
178
- call would report an EMPTY FILE. Both halves ship: it returns promptly, and it refuses.
158
+ - **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes, open the final component
159
+ `O_NOFOLLOW` so a symlink the box plants cannot redirect host I/O, and refuse anything that is not a
160
+ REGULAR file (see the notes for the FIFO that made a read hang).
179
161
 
180
162
  ### Options
181
163
 
@@ -238,32 +220,26 @@ you did not read.
238
220
  ## Prewarming: a box ready before the call arrives
239
221
 
240
222
  `prewarm: N` keeps N boxes started in advance, each holding a booted interpreter that has run nothing,
241
- so a `runCode` claims one instead of paying for a box start plus an interpreter boot. Measured on
242
- `python:3.12-slim`, six calls each: **14.2 ms p50 by default against 0.8 ms with `prewarm: 4`**, and
243
- 30.9 ms against 0.9 for the first call.
223
+ and refills in the background while your agent thinks. Measured on `python:3.12-slim`:
244
224
 
245
- **The pool also fills on that worker thread, so the first call is fast only once it HAS filled.**
246
- Measured: constructing with `prewarm: 4` and calling immediately gives 13.7 ms five times over,
247
- while half a second later the same burst reads 0.8, 0.6, 0.5, 0.6 for the first four and then
248
- 32.7 for the fifth, the pool empty. The table is the steady state, not the first moment.
225
+ | | first call | p50 within the burst |
226
+ |---|---:|---:|
227
+ | default | 30.9 ms | 14.2 ms |
228
+ | `prewarm: 4` | 0.9 ms | **0.8 ms** |
249
229
 
250
- The refill runs while your agent thinks, so it is off the caller's clock. That also says when it buys
251
- nothing: if calls arrive faster than the pool refills, the pool empties and you are back to the
252
- default cost. N is the burst you want covered, not a throughput knob.
230
+ **The pool covers a burst, not a rate**, and it refills in the background: past N the cost returns to
231
+ the default, and a call made immediately after construction pays the default until the boxes exist.
253
232
 
254
- Each prewarmed box serves ONE call and is discarded, so the isolation is unchanged: a fresh box per
255
- call, network off, the same caps. Only the moment of creation moves. That is the difference from
256
- `kernel()`, which deliberately shares one process across cells.
233
+ Each prewarmed box still serves ONE call and is thrown away, so the isolation is unchanged: only the
234
+ moment of creation moves. The pool key includes the image, the caps and the profiles, so a session never
235
+ receives a box built for another one.
257
236
 
258
237
  ```js
259
- await withSandbox({ image: "python:3.12-slim", prewarm: 4 }, async (sbx) => {
238
+ await kern.withSandbox({ image: "python:3.12-slim", prewarm: 4 }, async (sbx) => {
260
239
  const r = await sbx.runCode("print(1)"); // served from the pool
261
240
  });
262
241
  ```
263
242
 
264
- The pool key includes the image, the caps and the profiles, so a session with different settings never
265
- receives a box built for another one.
266
-
267
243
  ## Run pi's coding tools in a box
268
244
 
269
245
  [`integrations/pi`](https://github.com/getkern/kern/tree/main/integrations/pi) is an extension for
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.3";
39
+ const VERSION = "0.2.5";
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",