kern-sandbox 0.2.11 → 0.2.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.
- package/README.md +5 -2
- package/index.js +71 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -156,8 +156,11 @@ Every relaxing option says so in its name or docs:
|
|
|
156
156
|
- **output bounded**: `maxOutputBytes` (64 MiB each) so a flooding box cannot exhaust host RAM.
|
|
157
157
|
- **env off argv**: workload env is written to a private `0600` file, never `--env K=V` on the command
|
|
158
158
|
line, so a credential in `env` does not leak into `ps`.
|
|
159
|
-
- **mounts refused**:
|
|
160
|
-
|
|
159
|
+
- **mounts refused**: the host's own sources (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`,
|
|
160
|
+
`$HOME`, the docker socket), any path with a **credential directory** in it (`.ssh`, `.aws`, `.gnupg`,
|
|
161
|
+
`.kube`, `.docker`, `.azure`, `.password-store`, `.netrc`, `.git-credentials`, `.pypirc`, `.npmrc`),
|
|
162
|
+
**kern's own state** (`$XDG_RUNTIME_DIR/kern`, the image cache, the config dir: the sandbox's control
|
|
163
|
+
plane), and escaping targets.
|
|
161
164
|
- **workspace I/O contained**: `writeFile`/`readFile` reject `..` escapes, open the final component
|
|
162
165
|
`O_NOFOLLOW` so a symlink the box plants cannot redirect host I/O, and refuse anything that is not a
|
|
163
166
|
REGULAR file (see the notes for the FIFO that made a read hang).
|
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.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
|
|
@@ -1995,18 +1995,61 @@ class Sandbox {
|
|
|
1995
1995
|
|
|
1996
1996
|
/** Write `data` (Buffer|string) to `path` (workspace-relative) - host-direct, so the box sees it next
|
|
1997
1997
|
* run. The final component is opened O_NOFOLLOW: a symlink the box planted can't redirect the write. */
|
|
1998
|
+
/** Open the PARENT of a workspace-relative path as a directory fd, one component at a time, each with
|
|
1999
|
+
* `O_NOFOLLOW`, and return that fd. The caller then opens the leaf THROUGH it via
|
|
2000
|
+
* `/proc/self/fd/<dirfd>/<leaf>`, which the kernel resolves from the pinned descriptor rather than from
|
|
2001
|
+
* the path string, so no component can be swapped between the check and the open.
|
|
2002
|
+
*
|
|
2003
|
+
* WHY, and it is the one asymmetry a security review found in this file: `readFile` has a race-free
|
|
2004
|
+
* backstop (`_assertFdInWorkspace` on the open fd, line ~2094) and `writeFile` had only the `lstat`
|
|
2005
|
+
* pre-check in `_ensureParentDirs` plus `O_NOFOLLOW` on the LEAF. `O_NOFOLLOW` does not touch
|
|
2006
|
+
* intermediate components, so a box that swaps `mid` from a directory to a symlink in the window
|
|
2007
|
+
* between the descent and the open makes the host create (and `O_TRUNC`) a file wherever that link
|
|
2008
|
+
* points. Measured here: 426 074 attempts against a concurrent swapper produced 0 escapes and 3
|
|
2009
|
+
* refusals, so the window is real and microseconds wide - too narrow to demonstrate cheaply and too
|
|
2010
|
+
* cheap to close to leave open. Python's binding never had it: it descends with `openat`.
|
|
2011
|
+
*
|
|
2012
|
+
* The fd is the caller's to close. */
|
|
2013
|
+
_openParentDirNofollow(rel) {
|
|
2014
|
+
const base = fs.realpathSync(this._ws);
|
|
2015
|
+
const full = this._wsPath(rel);
|
|
2016
|
+
const relDir = path.relative(base, path.dirname(full));
|
|
2017
|
+
let dirFd = fs.openSync(base, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
2018
|
+
if (relDir === "" || relDir === ".") return dirFd;
|
|
2019
|
+
for (const part of relDir.split(path.sep)) {
|
|
2020
|
+
if (!part || part === ".") continue;
|
|
2021
|
+
let next;
|
|
2022
|
+
try {
|
|
2023
|
+
next = fs.openSync(
|
|
2024
|
+
`/proc/self/fd/${dirFd}/${part}`,
|
|
2025
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
|
2026
|
+
);
|
|
2027
|
+
} catch (e) {
|
|
2028
|
+
fs.closeSync(dirFd);
|
|
2029
|
+
throw pathRefusal("write", rel, e);
|
|
2030
|
+
}
|
|
2031
|
+
fs.closeSync(dirFd);
|
|
2032
|
+
dirFd = next;
|
|
2033
|
+
}
|
|
2034
|
+
return dirFd;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1998
2037
|
async writeFile(rel, data) {
|
|
1999
2038
|
this._requireEntered();
|
|
2000
2039
|
const full = this._wsPath(rel);
|
|
2001
|
-
this._ensureParentDirs(full); // symlink-safe
|
|
2040
|
+
this._ensureParentDirs(full); // creates missing dirs, symlink-safe, NOT mkdir -p (which follows one)
|
|
2002
2041
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
2042
|
+
// THE LEAF IS OPENED THROUGH A PINNED PARENT FD, not by path: see `_openParentDirNofollow`. The
|
|
2043
|
+
// pre-check above still runs, because it is what CREATES the missing directories; what it cannot do
|
|
2044
|
+
// is stay true between its own lstat and this open.
|
|
2045
|
+
const dirFd = this._openParentDirNofollow(rel);
|
|
2003
2046
|
let fd;
|
|
2004
2047
|
try {
|
|
2005
2048
|
// O_NONBLOCK for the same reason as readFile, and the write side is the WORSE of the two: opening
|
|
2006
2049
|
// a FIFO for writing blocks until a reader appears, and with the flag it fails outright (ENXIO)
|
|
2007
2050
|
// instead. Either way the call returns to the caller rather than parking there.
|
|
2008
2051
|
fd = fs.openSync(
|
|
2009
|
-
full
|
|
2052
|
+
`/proc/self/fd/${dirFd}/${path.basename(full)}`,
|
|
2010
2053
|
fs.constants.O_WRONLY |
|
|
2011
2054
|
fs.constants.O_CREAT |
|
|
2012
2055
|
fs.constants.O_TRUNC |
|
|
@@ -2016,7 +2059,11 @@ class Sandbox {
|
|
|
2016
2059
|
);
|
|
2017
2060
|
} catch (e) {
|
|
2018
2061
|
throw pathRefusal("write", rel, e);
|
|
2062
|
+
} finally {
|
|
2063
|
+
fs.closeSync(dirFd);
|
|
2019
2064
|
}
|
|
2065
|
+
// AND THE BACKSTOP THE READ PATH ALREADY HAD: where did the descriptor actually land?
|
|
2066
|
+
this._assertFdInWorkspace(fd, rel);
|
|
2020
2067
|
try {
|
|
2021
2068
|
// The file the box left at this name has to be a REGULAR file before we write into it: writing
|
|
2022
2069
|
// into a device node or a socket the box planted is host I/O it chose the target of.
|
|
@@ -2477,10 +2524,6 @@ function kernelDriver(outCap, resCap, hello = false) {
|
|
|
2477
2524
|
.replaceAll("__KERN_HELLO__", hello ? "1" : "0");
|
|
2478
2525
|
}
|
|
2479
2526
|
|
|
2480
|
-
/** A warm, persistent Python interpreter living in one long-lived box (see `Sandbox.kernel`). `runCode`
|
|
2481
|
-
* sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
|
|
2482
|
-
* captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
|
|
2483
|
-
* stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
|
|
2484
2527
|
/** The message for a host-side open the workspace boundary refused.
|
|
2485
2528
|
*
|
|
2486
2529
|
* ELOOP here is not a filesystem oddity, it is the boundary working: the final component is opened
|
|
@@ -2502,7 +2545,10 @@ function pathRefusal(verb, rel, e) {
|
|
|
2502
2545
|
);
|
|
2503
2546
|
return new SandboxError(`cannot ${verb} ${JSON.stringify(rel)}: ${e && e.message ? e.message : e}`);
|
|
2504
2547
|
}
|
|
2505
|
-
|
|
2548
|
+
/** A warm, persistent Python interpreter living in one long-lived box (see `Sandbox.kernel`). `runCode`
|
|
2549
|
+
* sends a cell over a length-prefixed pipe to the resident driver and resolves to an ExecutionResult with
|
|
2550
|
+
* captured stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box
|
|
2551
|
+
* stays network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
|
|
2506
2552
|
class Kernel {
|
|
2507
2553
|
constructor(sbx, timeoutS) {
|
|
2508
2554
|
this._sbx = sbx;
|
|
@@ -2766,12 +2812,28 @@ class Kernel {
|
|
|
2766
2812
|
"the kernel box was killed, and its memory cap was not enforced here (no cgroup delegation), so no memory limit was in force to attribute it to",
|
|
2767
2813
|
rc,
|
|
2768
2814
|
];
|
|
2769
|
-
if (this._sbx.memoryMb !== null)
|
|
2815
|
+
if (this._sbx.memoryMb !== null) {
|
|
2816
|
+
// A BINARY THAT DOES NOT REPORT THE SIGNAL CANNOT HAVE THIS SENTENCE PUT IN ITS MOUTH. MEASURED on
|
|
2817
|
+
// the released 0.9.32, which is what `install.sh` serves today: it writes two of the four teardown
|
|
2818
|
+
// bytes, so `workloadSignal` is null, and a cell that SEGFAULTED and a cell whose syscall the
|
|
2819
|
+
// seccomp filter refused both landed here and were told "an external kill", which is false for
|
|
2820
|
+
// both. The verdict cannot improve without the byte; the sentence can say so.
|
|
2821
|
+
if (kernWrotePayload && workloadSignal === null)
|
|
2822
|
+
return [
|
|
2823
|
+
"killed",
|
|
2824
|
+
"the kernel box was killed and the kernel reported no OOM against its memory cap. THIS kern " +
|
|
2825
|
+
"does not report which signal ended the box (it writes 2 of the 4 teardown bytes), so an " +
|
|
2826
|
+
"external kill, a crash in your own code and a syscall the sandbox refused are " +
|
|
2827
|
+
"indistinguishable from here: a newer kern separates them, and until then read the box's " +
|
|
2828
|
+
"stderr before concluding it was killed from outside",
|
|
2829
|
+
rc,
|
|
2830
|
+
];
|
|
2770
2831
|
return [
|
|
2771
2832
|
"killed",
|
|
2772
2833
|
"the kernel box was killed and the kernel reported no OOM against its memory cap: an external kill (`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory",
|
|
2773
2834
|
rc,
|
|
2774
2835
|
];
|
|
2836
|
+
}
|
|
2775
2837
|
return ["killed", "the kernel box exited", rc];
|
|
2776
2838
|
}
|
|
2777
2839
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
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",
|