kern-sandbox 0.2.28 → 0.2.30
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.
- package/README.md +27 -0
- package/index.js +58 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -73,6 +73,11 @@ cargo install --git https://github.com/getkern/kern getkern --locked
|
|
|
73
73
|
kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2.
|
|
74
74
|
Node 18+.
|
|
75
75
|
|
|
76
|
+
The first call on a machine that has never run it is the slow one: it pulls `python:3.12-slim` before
|
|
77
|
+
it can start a box. Every call after that reads the cached image, and the `startup_failed` row below
|
|
78
|
+
has the measured cost of that first read, which on a slow machine is large enough to trip a short
|
|
79
|
+
`timeoutS`.
|
|
80
|
+
|
|
76
81
|
**On a Mac this package installs but cannot run**, and it says so rather than sending you after a
|
|
77
82
|
download that does not exist: kern is Linux-only, because macOS has no namespaces and no cgroups. Run
|
|
78
83
|
inside a Linux VM (colima, Lima, OrbStack, UTM), install `kern` and this package there, and it behaves
|
|
@@ -259,6 +264,28 @@ or Redis connection under `egressAllow` cannot resolve its host. For a database,
|
|
|
259
264
|
`SandboxError`, so a caller can tell "this sandbox will not do that" from "the sandbox broke".
|
|
260
265
|
`DEFAULT_TMPFS_MB` and `version` are exported for callers that assert on them.
|
|
261
266
|
|
|
267
|
+
## The image decides more than the runtime does
|
|
268
|
+
|
|
269
|
+
`run_code` that imports two standard-library modules measures **46.8 ms** on the default
|
|
270
|
+
`python:3.12-slim`, against 13.8 ms for one that imports nothing. That tag ships 164 `.py` files in
|
|
271
|
+
the standard library and 9 `.pyc`, so every import compiles its source. Precompiling the bytecode is
|
|
272
|
+
one line and is worth **29 ms**, which is more than the box, the interpreter start and every runtime
|
|
273
|
+
flag combined:
|
|
274
|
+
|
|
275
|
+
```dockerfile
|
|
276
|
+
FROM python:3.12-slim
|
|
277
|
+
RUN python3 -m compileall -q -j 0 /usr/local/lib/python3.12
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| `run_code` | `python:3.12-slim` | precompiled |
|
|
281
|
+
|---|---:|---:|
|
|
282
|
+
| `print(1)` | 13.82 ms | 12.27 ms |
|
|
283
|
+
| `import json,re` | **46.82 ms** | **17.66 ms** |
|
|
284
|
+
|
|
285
|
+
Build it once and pass it: `run_code(..., image="my-python")`. The default stays the stock tag,
|
|
286
|
+
because an SDK that silently required a custom image would be worse than one that costs 29 ms and
|
|
287
|
+
says so. Measured on an Intel i7-14700KF, Linux 7.0.0, idle; [BENCHMARKS.md](https://github.com/getkern/kern/blob/main/BENCHMARKS.md) has the method.
|
|
288
|
+
|
|
262
289
|
## Prewarming: a box ready before the call arrives
|
|
263
290
|
|
|
264
291
|
`prewarm: N` keeps N boxes started in advance, each holding a booted interpreter that has run nothing,
|
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.
|
|
39
|
+
const VERSION = "0.2.29";
|
|
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
|
|
@@ -1418,6 +1418,46 @@ class Sandbox {
|
|
|
1418
1418
|
if (!(this.timeoutS > 0)) throw new SandboxError("timeoutS must be a positive number of seconds");
|
|
1419
1419
|
if (!(this.maxOutputBytes > 0)) throw new SandboxError("maxOutputBytes must be positive");
|
|
1420
1420
|
|
|
1421
|
+
// SHAPE GUARDS BEFORE ANYTHING CONSUMES THESE, the twin of the Python binding's and added after
|
|
1422
|
+
// the same outside review swept the constructor argument by argument.
|
|
1423
|
+
//
|
|
1424
|
+
// THE JS FAILURE IS WORSE THAN THE PYTHON ONE, which is why this is not just symmetry. Python
|
|
1425
|
+
// raises `AttributeError: 'list' object has no attribute 'items'` - useless, but obviously a
|
|
1426
|
+
// type error. `Object.entries()` does NOT throw on an array or a string: it hands back index
|
|
1427
|
+
// keys. MEASURED, `mounts: ["/tmp:/x"]` reached the mount validator as source `"0"` and reported
|
|
1428
|
+
// `mount source must be an absolute host path, got "0"`, naming a value the caller never wrote,
|
|
1429
|
+
// and `mounts: "/tmp:/x"` walked the string character by character and reported `cannot mount
|
|
1430
|
+
// over the box essential mount "/"`. Both refuse, so nothing unsafe happened; both send the
|
|
1431
|
+
// reader to look at a "0" or a "/" that exists nowhere in their code.
|
|
1432
|
+
//
|
|
1433
|
+
// `tmpfs` is deliberately NOT here: it documents an array form (a list of paths) and already
|
|
1434
|
+
// refuses a bare string by name.
|
|
1435
|
+
for (const [name, value] of [
|
|
1436
|
+
["mounts", this.mounts],
|
|
1437
|
+
["env", this.env],
|
|
1438
|
+
]) {
|
|
1439
|
+
if (value === null || value === undefined) continue;
|
|
1440
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1441
|
+
const example = name === "env" ? `{ KEY: "value" }` : `{ "/host/path": "/in/box" }`;
|
|
1442
|
+
throw new SandboxError(
|
|
1443
|
+
`${name} must be a plain object, not ${Array.isArray(value) ? "an array" : typeof value}: ` +
|
|
1444
|
+
`write ${name}: ${example}`,
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// A CALLBACK THAT IS NOT A FUNCTION is never called and says nothing, so the caller sees a
|
|
1449
|
+
// sandbox that produces no output and has nothing to debug.
|
|
1450
|
+
for (const [name, cb] of [
|
|
1451
|
+
["onStdout", this.onStdout],
|
|
1452
|
+
["onStderr", this.onStderr],
|
|
1453
|
+
]) {
|
|
1454
|
+
if (cb !== null && cb !== undefined && typeof cb !== "function") {
|
|
1455
|
+
throw new SandboxError(
|
|
1456
|
+
`${name} must be a function (it is handed one chunk at a time), not ${typeof cb}`,
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1421
1461
|
this._mountArgs = [];
|
|
1422
1462
|
const boundTargets = new Set();
|
|
1423
1463
|
if (this.mounts) {
|
|
@@ -1489,6 +1529,22 @@ class Sandbox {
|
|
|
1489
1529
|
if (!Array.isArray(this.capDrop))
|
|
1490
1530
|
throw new SandboxError("capDrop must be an array of capability names");
|
|
1491
1531
|
this._capDropArgs = this.capDrop.flatMap((c) => ["--cap-drop", validateCap(c)]);
|
|
1532
|
+
// SKIP THE UID RANGE EXACTLY WHEN THE CAPABILITY IT SERVES IS BEING DROPPED ANYWAY, which is the
|
|
1533
|
+
// default and costs a quarter of a cold box. `kern box --image` maps a sub-uid RANGE by default
|
|
1534
|
+
// (so an image that degrades privilege in its entrypoint works), and mapping it forks two SETUID
|
|
1535
|
+
// HELPERS. MEASURED: `parent:idmap` 22 us single-uid against ~1048 us ranged, and the whole box
|
|
1536
|
+
// 3234 against 4298 us on this class's argv - paired, core-pinned, -1083 us (25%).
|
|
1537
|
+
//
|
|
1538
|
+
// IT BUYS THIS SANDBOX NOTHING when `ALL` is dropped, measured rather than argued: `setuid(1000)`
|
|
1539
|
+
// inside a cell is refused either way under `--cap-drop ALL` (EPERM with the range, EINVAL
|
|
1540
|
+
// without). WITHOUT it the range does work, so this is conditional: `capDrop: []` is a documented
|
|
1541
|
+
// choice and keeps both the capability and the range.
|
|
1542
|
+
//
|
|
1543
|
+
// KEPT IDENTICAL TO THE PYTHON BINDING, including the condition: two spellings of one rule drift,
|
|
1544
|
+
// and a caller who moved between the SDKs would meet a different box shape in each.
|
|
1545
|
+
this._singleUid = this.capDrop.some(
|
|
1546
|
+
(c) => String(c).toUpperCase().replace(/^CAP_/, "") === "ALL",
|
|
1547
|
+
);
|
|
1492
1548
|
this._profileArgs = (this.profiles || []).map(validateProfile);
|
|
1493
1549
|
this._egressAllow = (this.egressAllow || []).map(validateDomain);
|
|
1494
1550
|
if (this.apparmor !== null) validateApparmor(this.apparmor);
|
|
@@ -1633,6 +1689,7 @@ class Sandbox {
|
|
|
1633
1689
|
}
|
|
1634
1690
|
// kern's own --timeout is a tight BACKSTOP just beyond our deadline; OUR wait is the authority.
|
|
1635
1691
|
argv.push(...this._capDropArgs);
|
|
1692
|
+
if (this._singleUid) argv.push("--no-uid-range");
|
|
1636
1693
|
argv.push("--timeout", String(Math.floor(timeoutS) + 5));
|
|
1637
1694
|
if (this.memoryMb !== null) argv.push("--memory", `${this.memoryMb}m`);
|
|
1638
1695
|
if (this.cpus !== null) argv.push("--cpus", String(this.cpus));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "kern is a fast, rootless sandbox and virtual resource runtime
|
|
3
|
+
"version": "0.2.30",
|
|
4
|
+
"description": "kern is a fast, rootless sandbox and virtual resource runtime; kern-sandbox is its Node/TypeScript binding. Run semi-trusted or agent-generated code (Python/JS/Bash) in a real, kernel-enforced box, one per call, with no cloud, no account and no VM. A kernel boundary, not a microVM: for deliberately hostile multi-tenant code, use one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|
|
7
7
|
"kern",
|