kern-sandbox 0.2.6 → 0.2.8
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 +8 -0
- package/index.js +61 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -216,6 +216,14 @@ cannot exfiltrate elsewhere. Mutually exclusive with `network: true`. The `setup
|
|
|
216
216
|
network to install dependencies; the allowlist governs the run phase, which is the one executing code
|
|
217
217
|
you did not read.
|
|
218
218
|
|
|
219
|
+
It is a **route-level** boundary, not proxy variables a program can ignore. Measured inside the box: a
|
|
220
|
+
raw socket to an IP returns `ENETUNREACH`, DNS does not resolve, and a request to a domain outside the
|
|
221
|
+
list is refused by the tunnel with `403`, while the same socket under `network: true` connects. The other
|
|
222
|
+
edge of that: a client which does not speak to an HTTP proxy has no path out at all, so a Postgres, MySQL
|
|
223
|
+
or Redis connection under `egressAllow` cannot resolve its host. For a database, the setting today is
|
|
224
|
+
`network: true`.
|
|
225
|
+
|
|
226
|
+
|
|
219
227
|
`kernel()` returns a `Kernel`, and a refused mount throws `MountRefused` rather than the generic
|
|
220
228
|
`SandboxError`, so a caller can tell "this sandbox will not do that" from "the sandbox broke".
|
|
221
229
|
`DEFAULT_TMPFS_MB` and `version` are exported for callers that assert on them.
|
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.8";
|
|
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
|
|
@@ -1227,6 +1227,15 @@ class Sandbox {
|
|
|
1227
1227
|
constructor(opts = {}) {
|
|
1228
1228
|
this.image = opts.image ?? DEFAULT_IMAGE;
|
|
1229
1229
|
this.setup = opts.setup ?? null;
|
|
1230
|
+
// `setup` is ONE SHELL COMMAND, and a list of package names is the natural first guess. Without this
|
|
1231
|
+
// it reaches `_runSetup` and dies as `TypeError: cmd.trim is not a function`, an internal error where
|
|
1232
|
+
// a sentence belongs (the Python binding had the same edge, as an AttributeError).
|
|
1233
|
+
if (this.setup !== null && typeof this.setup !== "string")
|
|
1234
|
+
throw new SandboxError(
|
|
1235
|
+
`setup must be a shell command STRING, not ${Array.isArray(this.setup) ? "an array" : typeof this.setup}: ` +
|
|
1236
|
+
'write setup: "pip install pandas matplotlib" for packages, or any one line the setup box ' +
|
|
1237
|
+
"should run (it runs once, with the network on)",
|
|
1238
|
+
);
|
|
1230
1239
|
this.workspace = opts.workspace ?? null;
|
|
1231
1240
|
this.memoryMb = opts.memoryMb === undefined ? 512 : opts.memoryMb;
|
|
1232
1241
|
this.cpus = opts.cpus ?? null;
|
|
@@ -1875,7 +1884,21 @@ class Sandbox {
|
|
|
1875
1884
|
_wsPath(rel) {
|
|
1876
1885
|
// Lexical containment: normalize `..`/`.`, require it stays under the workspace base. Symlinks in
|
|
1877
1886
|
// the final component are neutralized by O_NOFOLLOW on the actual open below.
|
|
1887
|
+
//
|
|
1888
|
+
// AN ABSOLUTE PATH IS NOT A WORKSPACE PATH, and it takes its own check because `path.join` KEEPS the
|
|
1889
|
+
// base for an absolute second argument while Python's `os.path.join` DROPS it. The same three lines
|
|
1890
|
+
// therefore refused in the Python binding and silently resolved `<workspace>/etc/passwd` here:
|
|
1891
|
+
// MEASURED, `readFile("/etc/passwd")` returned a decoy the box had planted at that relative path and
|
|
1892
|
+
// `writeFile("/etc/passwd")` wrote into it. The boundary held either way; the ANSWER was a different
|
|
1893
|
+
// file's contents than the one asked for, which is worse than an error. A caller who passes a host
|
|
1894
|
+
// path is asking for a host file, so the honest answer is a refusal.
|
|
1878
1895
|
const base = this._ws;
|
|
1896
|
+
if (path.isAbsolute(rel))
|
|
1897
|
+
throw new SandboxError(
|
|
1898
|
+
`path escapes the workspace: ${JSON.stringify(rel)} is absolute, and these calls take a path ` +
|
|
1899
|
+
"RELATIVE to the workspace. Nothing outside it is readable or writable through them, and an " +
|
|
1900
|
+
"absolute path is NOT reinterpreted as a workspace one",
|
|
1901
|
+
);
|
|
1879
1902
|
const full = path.normalize(path.join(base, rel));
|
|
1880
1903
|
if (full !== base && !full.startsWith(base + path.sep))
|
|
1881
1904
|
throw new SandboxError(`path escapes the workspace: ${JSON.stringify(rel)}`);
|
|
@@ -1933,7 +1956,7 @@ class Sandbox {
|
|
|
1933
1956
|
0o644,
|
|
1934
1957
|
);
|
|
1935
1958
|
} catch (e) {
|
|
1936
|
-
throw
|
|
1959
|
+
throw pathRefusal("write", rel, e);
|
|
1937
1960
|
}
|
|
1938
1961
|
try {
|
|
1939
1962
|
// The file the box left at this name has to be a REGULAR file before we write into it: writing
|
|
@@ -2006,7 +2029,7 @@ class Sandbox {
|
|
|
2006
2029
|
// a denial of service the workspace hands out for free, and O_NOFOLLOW does not touch it.
|
|
2007
2030
|
fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
2008
2031
|
} catch (e) {
|
|
2009
|
-
throw
|
|
2032
|
+
throw pathRefusal("read", rel, e);
|
|
2010
2033
|
}
|
|
2011
2034
|
try {
|
|
2012
2035
|
this._assertFdInWorkspace(fd, rel); // race-free backstop: a swapped-in parent symlink is caught here
|
|
@@ -2399,6 +2422,28 @@ function kernelDriver(outCap, resCap, hello = false) {
|
|
|
2399
2422
|
* sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
|
|
2400
2423
|
* captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
|
|
2401
2424
|
* stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
|
|
2425
|
+
/** The message for a host-side open the workspace boundary refused.
|
|
2426
|
+
*
|
|
2427
|
+
* ELOOP here is not a filesystem oddity, it is the boundary working: the final component is opened
|
|
2428
|
+
* O_NOFOLLOW, so a symlink the BOX planted at a path the host is about to touch fails instead of
|
|
2429
|
+
* redirecting. Raw, it reads `ELOOP: too many symbolic links encountered`, which sends a reader looking
|
|
2430
|
+
* for a broken link chain when what happened is an attempt to reach a host file. */
|
|
2431
|
+
function pathRefusal(verb, rel, e) {
|
|
2432
|
+
if (e && e.code === "ELOOP")
|
|
2433
|
+
return new SandboxError(
|
|
2434
|
+
`refusing to ${verb} ${JSON.stringify(rel)}: a component of that path is a SYMLINK. Host-side ` +
|
|
2435
|
+
"reads and writes never follow one (O_NOFOLLOW), because a link planted inside the workspace is " +
|
|
2436
|
+
"how a box reaches a host file it was not given (the kernel reports this as ELOOP). Remove it, " +
|
|
2437
|
+
"or name the file you meant",
|
|
2438
|
+
);
|
|
2439
|
+
if (e && e.code === "ENXIO")
|
|
2440
|
+
return new SandboxError(
|
|
2441
|
+
`refusing to ${verb} ${JSON.stringify(rel)}: it is a FIFO with no reader. Opening one for writing ` +
|
|
2442
|
+
"would block until the box chose to read, so the open is non-blocking and fails instead",
|
|
2443
|
+
);
|
|
2444
|
+
return new SandboxError(`cannot ${verb} ${JSON.stringify(rel)}: ${e && e.message ? e.message : e}`);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2402
2447
|
class Kernel {
|
|
2403
2448
|
constructor(sbx, timeoutS) {
|
|
2404
2449
|
this._sbx = sbx;
|
|
@@ -2417,6 +2462,11 @@ class Kernel {
|
|
|
2417
2462
|
this._waiters = []; // FIFO of { resolve, timer }; one reply per request keeps them in order
|
|
2418
2463
|
this._stderr = Buffer.alloc(0);
|
|
2419
2464
|
this._dead = false;
|
|
2465
|
+
// WHY THE CAUSE IS KEPT. Every death funnels through `_teardownResult`, which KNOWS what ended the
|
|
2466
|
+
// kernel, and the next cell then threw "a prior cell timed out, or the box exited" - two guesses
|
|
2467
|
+
// where the answer was in hand (MEASURED: a cell that blew the memory cap produced
|
|
2468
|
+
// `fault.type === "oom"`, and the very next cell blamed a timeout). If it is known, name it.
|
|
2469
|
+
this._death = null;
|
|
2420
2470
|
// kern's KERN_STARTED_FD bytes for a RESIDENT box: the enforcement byte (2nd) and the OOM-outcome
|
|
2421
2471
|
// byte (3rd). kern writes them only at box teardown (a cell kills the kernel), so they arrive
|
|
2422
2472
|
// ~concurrent with the death we detect on stdout; read once, bounded, on death (`_readCapSignal`).
|
|
@@ -2522,7 +2572,13 @@ class Kernel {
|
|
|
2522
2572
|
|
|
2523
2573
|
async runCode(code, { timeoutS } = {}) {
|
|
2524
2574
|
if (!this._child) throw new SandboxError("kernel not started");
|
|
2525
|
-
if (this._dead)
|
|
2575
|
+
if (this._dead) {
|
|
2576
|
+
const why = this._death ? `a prior cell ended it (${this._death})` : "it was closed";
|
|
2577
|
+
throw new SandboxError(
|
|
2578
|
+
`kernel is dead: ${why}. Files written to the workspace are still there; names and imports ` +
|
|
2579
|
+
"from the earlier cells are gone. Open a new one with `sbx.kernel()`"
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2526
2582
|
if (typeof code !== "string" || code.includes("\0"))
|
|
2527
2583
|
throw new SandboxError("code must be a string with no NUL byte");
|
|
2528
2584
|
const eff = timeoutS != null ? this._sbx._effTimeout(timeoutS) : this._timeout;
|
|
@@ -2681,6 +2737,7 @@ class Kernel {
|
|
|
2681
2737
|
}
|
|
2682
2738
|
|
|
2683
2739
|
_teardownResult(type, message, started, exitCode = -1) {
|
|
2740
|
+
this._death = type === null ? "the code crashed" : type;
|
|
2684
2741
|
this._kill();
|
|
2685
2742
|
// Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
|
|
2686
2743
|
// does not return a hollow result. timeout/killed stay as data on the returned result.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|