kern-sandbox 0.2.35 → 0.2.36
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 +2 -0
- package/index.d.ts +2 -0
- package/index.js +509 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -221,6 +221,8 @@ new Sandbox({
|
|
|
221
221
|
requireLimits, // default false; true = FAIL-CLOSED (refuse to start unless caps enforced). NOT
|
|
222
222
|
// enforceLimits (that picks the cap PATH); mutually exclusive with KERN_ALLOW_UNCAPPED env.
|
|
223
223
|
depsReadonly, // default TRUE: runCode cannot modify what setup= installed
|
|
224
|
+
pycCache, // default TRUE: precompile the image's stdlib ONCE into a per-image cache and
|
|
225
|
+
// mount it READ-ONLY in every box. Imports 2.7-4.5x faster; false turns it off.
|
|
224
226
|
trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
|
|
225
227
|
onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
|
|
226
228
|
onStderr, // (chunk: Buffer) => void, live stderr streaming
|
package/index.d.ts
CHANGED
|
@@ -154,6 +154,8 @@ export interface SandboxOptions {
|
|
|
154
154
|
enforceLimits?: boolean;
|
|
155
155
|
/** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default false. */
|
|
156
156
|
depsReadonly?: boolean;
|
|
157
|
+
/** Compile this image's stdlib once and mount it read-only in every box (default true). */
|
|
158
|
+
pycCache?: boolean;
|
|
157
159
|
/** true (default) populates result.files by walking the workspace before AND after each call (O(N) in
|
|
158
160
|
* file count; a long session that accretes files slows every runCode). false = result.files [], O(1). */
|
|
159
161
|
trackFiles?: boolean;
|
package/index.js
CHANGED
|
@@ -36,11 +36,405 @@ 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.36";
|
|
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
|
|
43
43
|
const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
|
|
44
|
+
|
|
45
|
+
/** Where the shared stdlib bytecode cache is mounted inside a box, READ-ONLY.
|
|
46
|
+
*
|
|
47
|
+
* WHY, measured. `python:3.12-slim` ships its standard library as `.py` with no `.pyc`, and a box
|
|
48
|
+
* mounts its root read-only, so every box recompiles what it imports and throws the result away:
|
|
49
|
+
* `import json,re` costs 45.5 ms on an i7-14700KF and 172.0 ms on a 4-vCPU VPS, against 13.1 ms there
|
|
50
|
+
* for a box running `/bin/true`. One box compiles the stdlib once per image into a host directory and
|
|
51
|
+
* every later box mounts it read-only with `PYTHONPYCACHEPREFIX`: 45.50 -> 19.68 ms, 7 alternated pairs.
|
|
52
|
+
*
|
|
53
|
+
* READ-ONLY IS THE DESIGN, not a precaution. A writable shared cache is code execution across calls:
|
|
54
|
+
* those `.pyc` are timestamp-validated, so a cell could rewrite `json/__init__.pyc` with a payload,
|
|
55
|
+
* re-paste the legitimate header and have the next cell import it. Verified here: a cell's write comes
|
|
56
|
+
* back EROFS. And a cache from the WRONG image is safe rather than wrong - CPython validates each file
|
|
57
|
+
* against its source's mtime and size, so mismatched bytecode is ignored and recompiled (verified by
|
|
58
|
+
* mounting a 3.12 cache into 3.11: correct answers, reported 3.11.16). Mirrors `_PYC_MOUNT` in Python.
|
|
59
|
+
*/
|
|
60
|
+
const PYC_MOUNT = "/kern-pyc";
|
|
61
|
+
/** `workers=1`: more than one worker uses multiprocessing, which needs a temp dir, and a box with a
|
|
62
|
+
* read-only root and no tmpfs has none (measured: it dies with FileNotFoundError). `quiet=2` keeps a
|
|
63
|
+
* file that will not compile off stderr - bytecode is an optimisation.
|
|
64
|
+
*
|
|
65
|
+
* EVERY IMPORT ROOT, not just the stdlib: `PYTHONPYCACHEPREFIX` REPLACES the in-tree `__pycache__`
|
|
66
|
+
* rather than adding to it, so a root left out has its shipped bytecode made invisible and is
|
|
67
|
+
* recompiled in every box. On `python:3.12-slim` purelib happens to sit under stdlib; on
|
|
68
|
+
* `debian:13-slim` with python3 from apt it does not, and `sys.path` holds five separate roots.
|
|
69
|
+
*
|
|
70
|
+
* CHECKED_HASH rather than the default timestamp validation. Measured: two images, same CPython 3.12,
|
|
71
|
+
* one stdlib file edited to a different value of the SAME LENGTH with the mtime preserved - with
|
|
72
|
+
* timestamps the box ran the OLD code, with CHECKED_HASH the new one. Reproducible builds (BuildKit
|
|
73
|
+
* rewrite-timestamp, apko, Nix, distroless) pin mtimes by construction, so this is not exotic. It
|
|
74
|
+
* costs nothing measurable and makes the cache key a performance hint, not a correctness dependency.
|
|
75
|
+
* Mirrors `_PYC_BUILD_CODE`. */
|
|
76
|
+
const PYC_BUILD_CODE =
|
|
77
|
+
"import compileall,os,py_compile,sys,sysconfig;" +
|
|
78
|
+
"r={p for p in sys.path if p and os.path.isdir(p)};" +
|
|
79
|
+
"r|={v for v in (sysconfig.get_paths().get(k) for k in " +
|
|
80
|
+
"('stdlib','platstdlib','purelib','platlib')) if v and os.path.isdir(v)};" +
|
|
81
|
+
"r={p for p in r if not any(p!=q and p.startswith(q.rstrip('/')+'/') for q in r)};" +
|
|
82
|
+
"sys.exit(0 if all([compileall.compile_dir(p,quiet=2,workers=1,force=True," +
|
|
83
|
+
"invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH) for p in sorted(r)]) else 1)";
|
|
84
|
+
/** A ceiling on what one image may publish: the stdlib is ~20 MiB, so this is not tight. It exists so
|
|
85
|
+
* a hostile image cannot fill the host's disk from the box that exists to make things faster. */
|
|
86
|
+
const PYC_MAX_BYTES = 512 * 1024 * 1024;
|
|
87
|
+
/** How many image caches to keep, least-recently-ADOPTED evicted first. Not a measurement: "more
|
|
88
|
+
* images than a session mixes, fewer than a disk notices" at ~20 MiB each. Mirrors `_PYC_KEEP`. */
|
|
89
|
+
const PYC_KEEP = 8;
|
|
90
|
+
/** A build killed by a SIGKILL leaves its `.tmp-` tree forever: nothing in the happy path removes it,
|
|
91
|
+
* because the happy path is the one that did not run. A day is long enough that a build still running
|
|
92
|
+
* is never what gets deleted. Mirrors `_PYC_DEBRIS_MAX_AGE_S`. */
|
|
93
|
+
const PYC_DEBRIS_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
94
|
+
/** The name fragments marking a directory as NOT a cache: one being written, one being deleted. */
|
|
95
|
+
const PYC_DEBRIS_MARKS = [".tmp-", ".trash-"];
|
|
96
|
+
|
|
97
|
+
/** Remove a cache tree, renaming it out of the way first. Never throws.
|
|
98
|
+
*
|
|
99
|
+
* THE RENAME IS THE POINT: `rm -r` walks and unlinks, so a tree being deleted is for a while a tree
|
|
100
|
+
* with half its files, and a session mounting it in that window gets a partial stdlib. A rename is
|
|
101
|
+
* atomic, and kern CREATES a `-v` source that does not exist (measured), so a name resolving to
|
|
102
|
+
* nothing just means the box compiles from source. Mirrors `_pyc_discard`. */
|
|
103
|
+
function pycDiscard(target) {
|
|
104
|
+
const trash = `${target}.trash-${crypto.randomBytes(4).toString("hex")}`;
|
|
105
|
+
try {
|
|
106
|
+
fs.renameSync(target, trash);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
fs.rmSync(trash, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Keep the `keep` most recently adopted caches, remove stale debris. Never throws.
|
|
114
|
+
*
|
|
115
|
+
* LEAST RECENTLY ADOPTED, which `open()` records with a `utimes` on the directory - not least recently
|
|
116
|
+
* BUILT, or an image built once and used daily would go before one built yesterday and never used.
|
|
117
|
+
* The directory's own mtime carries it, so there is no marker file: one would be visible inside every
|
|
118
|
+
* box and would have to be excluded from the check that refuses everything which is not a `.pyc`.
|
|
119
|
+
* Mirrors `_pyc_sweep`. */
|
|
120
|
+
function pycSweep(root, keep = PYC_KEEP) {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const caches = [];
|
|
123
|
+
const doomed = [];
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const ent of entries) {
|
|
131
|
+
const full = path.join(root, ent.name);
|
|
132
|
+
let st;
|
|
133
|
+
try {
|
|
134
|
+
st = fs.lstatSync(full);
|
|
135
|
+
if (!st.isDirectory()) continue;
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (PYC_DEBRIS_MARKS.some((m) => ent.name.includes(m))) {
|
|
140
|
+
if (now - st.mtimeMs > PYC_DEBRIS_MAX_AGE_MS) doomed.push(full);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
caches.push([st.mtimeMs, full]);
|
|
144
|
+
}
|
|
145
|
+
caches.sort((a, b) => b[0] - a[0]);
|
|
146
|
+
for (const [, full] of caches.slice(keep)) doomed.push(full);
|
|
147
|
+
for (const full of doomed) pycDiscard(full);
|
|
148
|
+
}
|
|
149
|
+
/** One build per (process, destination), keyed so ten sessions opened at once on one image start one
|
|
150
|
+
* compile and not ten. A MAP OF PROMISES rather than a set of names: a second caller gets the promise
|
|
151
|
+
* of the build already in flight, which is what lets a test await the real thing instead of polling
|
|
152
|
+
* for a directory - a poll would race the build, and it raced the test's own teardown first. */
|
|
153
|
+
const PYC_BUILDS = new Map();
|
|
154
|
+
/** Set once this process has swept: adopting a cache in a hundred sessions costs one scan. */
|
|
155
|
+
let PYC_SWEPT = false;
|
|
156
|
+
|
|
157
|
+
/** The host directory holding one bytecode cache per image: $XDG_CACHE_HOME, else ~/.cache. */
|
|
158
|
+
function pycRoot() {
|
|
159
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
160
|
+
return path.join(base, "kern-sandbox", "pyc");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** kern's own defaults, from `kern-oci/src/pull.rs`. Copied because they cross a process boundary. */
|
|
164
|
+
const OCI_DEFAULT_REGISTRY = "registry-1.docker.io";
|
|
165
|
+
const OCI_DEFAULT_TAG = "latest";
|
|
166
|
+
|
|
167
|
+
/** `[name, tag]` iff the reference ends in an explicit tag. Mirrors `split_tag` in kern-oci: a
|
|
168
|
+
* trailing `:x` is a tag only when `x` has no `/`, or `localhost:5000/img` reads its PORT as one. */
|
|
169
|
+
function ociSplitTag(image) {
|
|
170
|
+
const i = image.lastIndexOf(":");
|
|
171
|
+
if (i <= 0) return null;
|
|
172
|
+
const name = image.slice(0, i);
|
|
173
|
+
const tag = image.slice(i + 1);
|
|
174
|
+
return !tag.includes("/") && name ? [name, tag] : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One canonical string per image, so one cache directory per image.
|
|
178
|
+
*
|
|
179
|
+
* `python:3.12-slim`, `docker.io/library/python:3.12-slim` and `index.docker.io/python:3.12-slim` are
|
|
180
|
+
* one image to kern and were three cache directories here, because the key was the raw string the
|
|
181
|
+
* caller typed. A faithful port of `parse_ref` in `kern-oci/src/pull.rs`, verified against its actual
|
|
182
|
+
* output on twelve references including digest pins. Mirrors `_oci_canonical_ref`. */
|
|
183
|
+
function ociCanonicalRef(image) {
|
|
184
|
+
if (!image) return image;
|
|
185
|
+
let name;
|
|
186
|
+
let reference;
|
|
187
|
+
const at = image.indexOf("@");
|
|
188
|
+
if (at > 0 && at < image.length - 1) {
|
|
189
|
+
// A digest pin splits at `@` FIRST: splitting on the last `:` would tear `sha256:<hex>` in half.
|
|
190
|
+
// The digest wins over any tag, so a trailing `:tag` on the name is dropped.
|
|
191
|
+
const head = image.slice(0, at);
|
|
192
|
+
const split = ociSplitTag(head);
|
|
193
|
+
name = split ? split[0] : head;
|
|
194
|
+
reference = image.slice(at + 1);
|
|
195
|
+
} else {
|
|
196
|
+
const split = ociSplitTag(image);
|
|
197
|
+
if (split) [name, reference] = split;
|
|
198
|
+
else [name, reference] = [image, OCI_DEFAULT_TAG];
|
|
199
|
+
}
|
|
200
|
+
let registry;
|
|
201
|
+
let repo;
|
|
202
|
+
const slash = name.indexOf("/");
|
|
203
|
+
const host = slash > 0 ? name.slice(0, slash) : "";
|
|
204
|
+
// The first segment is a REGISTRY only if it looks like a host; otherwise `user/img` is a Docker Hub
|
|
205
|
+
// repository, not a hostname.
|
|
206
|
+
if (slash > 0 && (host.includes(".") || host.includes(":") || host === "localhost")) {
|
|
207
|
+
registry = host;
|
|
208
|
+
repo = name.slice(slash + 1);
|
|
209
|
+
} else {
|
|
210
|
+
registry = OCI_DEFAULT_REGISTRY;
|
|
211
|
+
repo = name;
|
|
212
|
+
}
|
|
213
|
+
if (registry === "docker.io" || registry === "index.docker.io") registry = OCI_DEFAULT_REGISTRY;
|
|
214
|
+
// `library/` only on Docker Hub: `ghcr.io/alpine` means what it says.
|
|
215
|
+
if (registry === OCI_DEFAULT_REGISTRY && !repo.includes("/")) repo = `library/${repo}`;
|
|
216
|
+
return `${registry}/${repo}:${reference}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Where this image's cache lives, keyed on the image REFERENCE hashed for a safe filename.
|
|
220
|
+
*
|
|
221
|
+
* The reference and not a content digest, honestly because kern exposes no digest a caller can read
|
|
222
|
+
* cheaply. A moved tag therefore yields bytecode that no longer validates, which CPython handles by
|
|
223
|
+
* recompiling: the cost of the imperfect key is a slow call, never a wrong one. Mirrors `_pyc_dir_for`.
|
|
224
|
+
*/
|
|
225
|
+
function pycDirFor(image) {
|
|
226
|
+
// The full digest: the key is no longer load-bearing for correctness, and a truncation saved 48
|
|
227
|
+
// characters of path against two images sharing a cache directory.
|
|
228
|
+
const key = crypto.createHash("sha256").update(ociCanonicalRef(image), "utf8").digest("hex");
|
|
229
|
+
return path.join(pycRoot(), key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** True iff mounting this cache directory is allowed by the same policy as every other mount.
|
|
233
|
+
*
|
|
234
|
+
* WHY AN ANCESTOR: `validateMount` resolves the source with `realpathSync` and therefore needs it to
|
|
235
|
+
* EXIST, and on the first session the cache does not yet. So the nearest existing ancestor is checked
|
|
236
|
+
* instead, which asks the same question: the components this module appends below it ("kern-sandbox",
|
|
237
|
+
* "pyc", a hex digest) are fixed and are in no refused set, so a subtree is allowed exactly when its
|
|
238
|
+
* ancestor is. Python checks the full path because its validator has a purely lexical half; the
|
|
239
|
+
* mechanism differs, the policy asked is the same. Mirrors the `_validate_mount_lexical` call there.
|
|
240
|
+
*/
|
|
241
|
+
function pycMountAllowed(dest) {
|
|
242
|
+
let probe = path.resolve(dest);
|
|
243
|
+
while (!fs.existsSync(probe)) {
|
|
244
|
+
const up = path.dirname(probe);
|
|
245
|
+
if (up === probe) return false; // walked past the root without finding anything that exists
|
|
246
|
+
probe = up;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
validateMount(probe, PYC_MOUNT);
|
|
250
|
+
return true;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** True iff no component of the cache path is a symlink.
|
|
257
|
+
*
|
|
258
|
+
* `validateMount` resolves the SOURCE with realpath, but it is called on the nearest existing ancestor
|
|
259
|
+
* and a bind mount follows links: a symlink planted at `<cache>/kern-sandbox/pyc/<key>` pointing at
|
|
260
|
+
* `~/.ssh` would mount the real directory into every box of every later session, read-only, and
|
|
261
|
+
* reading is what exfiltration needs. It is not only the user who can plant it: a CELL holding a
|
|
262
|
+
* writable volume that contains the cache root can, and then every future process is affected - the
|
|
263
|
+
* `.deps` poisoning vector escaping the session that produced it. Mirrors `_pyc_path_has_no_symlink`.
|
|
264
|
+
*/
|
|
265
|
+
function pycHasContent(dest) {
|
|
266
|
+
// AN EMPTY DIRECTORY IS NOT AN ABSENT ONE, and the difference silences the whole feature. Measured: a
|
|
267
|
+
// sweep in another process discards a tree this session has mounted, kern then RECREATES the missing
|
|
268
|
+
// -v source as an empty directory, and from then on every session adopts that husk, mounts it, finds
|
|
269
|
+
// no bytecode and compiles from source - permanently, with nothing to rebuild it because a cache
|
|
270
|
+
// "exists". Treating empty as absent repairs it: the adoption is refused, a build starts, and a
|
|
271
|
+
// rename onto an EMPTY directory SUCCEEDS (verified; onto a non-empty one it is ENOTEMPTY, which is
|
|
272
|
+
// the check pycBuild relies on when two processes race).
|
|
273
|
+
try {
|
|
274
|
+
const it = fs.opendirSync(dest);
|
|
275
|
+
try {
|
|
276
|
+
return it.readSync() !== null;
|
|
277
|
+
} finally {
|
|
278
|
+
it.closeSync();
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pycPathHasNoSymlink(dest) {
|
|
286
|
+
let cur = path.resolve(dest);
|
|
287
|
+
for (;;) {
|
|
288
|
+
try {
|
|
289
|
+
if (fs.lstatSync(cur).isSymbolicLink()) return false;
|
|
290
|
+
} catch {
|
|
291
|
+
/* does not exist: nothing to follow */
|
|
292
|
+
}
|
|
293
|
+
const up = path.dirname(cur);
|
|
294
|
+
if (up === cur) return true;
|
|
295
|
+
cur = up;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True iff the built tree is only directories and `.pyc` files and fits under the size ceiling.
|
|
300
|
+
*
|
|
301
|
+
* The build box runs the CALLER'S image with this directory writable, so its contents are chosen by
|
|
302
|
+
* that image. Inside a later box a symlink is harmless, but this tree also lives on the HOST, where a
|
|
303
|
+
* backup, an indexer or the cache's own eviction will walk it. Mirrors `_pyc_tree_is_publishable`. */
|
|
304
|
+
function pycTreeIsPublishable(root) {
|
|
305
|
+
let total = 0;
|
|
306
|
+
const walk = (dir) => {
|
|
307
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
308
|
+
const full = path.join(dir, ent.name);
|
|
309
|
+
const st = fs.lstatSync(full);
|
|
310
|
+
if (st.isSymbolicLink()) return false;
|
|
311
|
+
if (st.isDirectory()) {
|
|
312
|
+
if (!walk(full)) return false;
|
|
313
|
+
} else if (!st.isFile() || !ent.name.endsWith(".pyc")) {
|
|
314
|
+
return false;
|
|
315
|
+
} else {
|
|
316
|
+
total += st.size;
|
|
317
|
+
if (total > PYC_MAX_BYTES) return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
};
|
|
322
|
+
try {
|
|
323
|
+
return walk(root);
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Compile `image`'s stdlib into `dest`, atomically. Never throws: nothing waits for this.
|
|
330
|
+
*
|
|
331
|
+
* Built into a private sibling and renamed into place, so a box mounts a complete tree or none: a
|
|
332
|
+
* partial stdlib would have a box importing a truncated module. Two processes racing both build and
|
|
333
|
+
* the loser's tree is removed. Mirrors `_pyc_build`. */
|
|
334
|
+
function pycBuild(kernBin, image, dest, timeoutS) {
|
|
335
|
+
const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
336
|
+
try {
|
|
337
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
338
|
+
fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
|
|
339
|
+
} catch {
|
|
340
|
+
return Promise.resolve();
|
|
341
|
+
}
|
|
342
|
+
// RETURNS A PROMISE THAT NOTHING IN PRODUCTION AWAITS. `pycStartBuild` drops it on purpose: the
|
|
343
|
+
// caller's first call must not wait for a cache fill. The tests await it, because a test that polled
|
|
344
|
+
// for a directory would be a timing race pretending to be an assertion.
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
// AFTER the publish and after the failure alike: a build that produced nothing is still the moment
|
|
347
|
+
// to notice that eight other caches are older than this one.
|
|
348
|
+
const done = () => {
|
|
349
|
+
pycSweep(path.dirname(dest));
|
|
350
|
+
resolve();
|
|
351
|
+
};
|
|
352
|
+
const finish = () => {
|
|
353
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
354
|
+
done();
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
// A dedicated argv, not `_baseArgv`: that one mounts the session's workspace, writes an env file
|
|
358
|
+
// and would add THIS cache read-only, which is what must not happen while it is being written.
|
|
359
|
+
const argv = [
|
|
360
|
+
"box", `kern-pyc-${crypto.randomBytes(4).toString("hex")}`,
|
|
361
|
+
"--image", image, "--ro",
|
|
362
|
+
"-v", `${tmp}:${PYC_MOUNT}`,
|
|
363
|
+
"--env", `PYTHONPYCACHEPREFIX=${PYC_MOUNT}`,
|
|
364
|
+
"--cap-drop", "ALL",
|
|
365
|
+
// CAPPED LIKE ANY OTHER BOX: the command is ours, the interpreter running it is the caller's
|
|
366
|
+
// image, and this package's claim is that an image gets no uncapped process. Mirrors Python.
|
|
367
|
+
"--memory", "512m",
|
|
368
|
+
"--pids-limit", "256",
|
|
369
|
+
"--timeout", String(Math.trunc(timeoutS)),
|
|
370
|
+
"--", "python3", "-c", PYC_BUILD_CODE,
|
|
371
|
+
];
|
|
372
|
+
// ASYNCHRONOUS, and `spawnSync` was the first version. It blocks the event loop for the whole
|
|
373
|
+
// build - 1.2 s on a desktop, 5 s on the VPS - which in a server process is not a stall but an
|
|
374
|
+
// outage: every request in flight waits for a cache fill. Nothing waits for this result, so there
|
|
375
|
+
// is no reason for it to hold the loop at all.
|
|
376
|
+
const child = spawn(kernBin, argv, { stdio: "ignore", detached: false });
|
|
377
|
+
const killer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
|
|
378
|
+
if (typeof killer.unref === "function") killer.unref();
|
|
379
|
+
child.on("error", () => {
|
|
380
|
+
clearTimeout(killer);
|
|
381
|
+
finish();
|
|
382
|
+
});
|
|
383
|
+
child.on("close", (code) => {
|
|
384
|
+
clearTimeout(killer);
|
|
385
|
+
try {
|
|
386
|
+
// An image without python3 leaves no cache and no trace: the next session runs as before.
|
|
387
|
+
if (code === 0 && fs.readdirSync(tmp).length > 0 && pycTreeIsPublishable(tmp)) {
|
|
388
|
+
fs.renameSync(tmp, dest);
|
|
389
|
+
done();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
/* fall through to the cleanup below */
|
|
394
|
+
}
|
|
395
|
+
finish();
|
|
396
|
+
});
|
|
397
|
+
} catch {
|
|
398
|
+
/* a cache fill has no failure the caller can act on */
|
|
399
|
+
finish();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Start one background build per image, at most once per process. Returns immediately.
|
|
405
|
+
*
|
|
406
|
+
* `setImmediate` rather than a worker thread: `spawnSync` would block the event loop, and this must
|
|
407
|
+
* never delay the caller's first call. Mirrors `_pyc_start_build`. */
|
|
408
|
+
/** Sweep the cache once per process, off the critical path. Returns a promise for the tests.
|
|
409
|
+
*
|
|
410
|
+
* The sweep used to run only at the end of a build, so a process that always found its cache already
|
|
411
|
+
* there never evicted anything: the bound existed only for callers who happened to compile. A
|
|
412
|
+
* long-lived server adopting one cache for weeks is exactly the process that should age the others.
|
|
413
|
+
* `setImmediate` because it is a readdir plus a stat per entry and a caller's first call pays nothing.
|
|
414
|
+
* Mirrors `_pyc_start_sweep`. */
|
|
415
|
+
function pycStartSweep(root) {
|
|
416
|
+
if (PYC_SWEPT) return Promise.resolve();
|
|
417
|
+
PYC_SWEPT = true;
|
|
418
|
+
return new Promise((resolve) => {
|
|
419
|
+
const t = setImmediate(() => {
|
|
420
|
+
pycSweep(root);
|
|
421
|
+
resolve();
|
|
422
|
+
});
|
|
423
|
+
if (typeof t.unref === "function") t.unref();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function pycStartBuild(kernBin, image, dest, timeoutS) {
|
|
428
|
+
const inFlight = PYC_BUILDS.get(dest);
|
|
429
|
+
if (inFlight) return inFlight;
|
|
430
|
+
// `pycBuild` is already asynchronous (it spawns and returns), so there is nothing to defer. The
|
|
431
|
+
// promise is returned for the tests and dropped by `open()`: a caller's first call must not wait on
|
|
432
|
+
// a cache fill. A test that polled for the directory instead would be a timing race pretending to be
|
|
433
|
+
// an assertion, and it would also race the test's own teardown - which is how this was found.
|
|
434
|
+
const started = pycBuild(kernBin, image, dest, timeoutS);
|
|
435
|
+
PYC_BUILDS.set(dest, started);
|
|
436
|
+
return started;
|
|
437
|
+
}
|
|
44
438
|
const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
|
|
45
439
|
// One file per CALL, `.kern-env.<box-name>`. A single fixed name made concurrent calls on the same
|
|
46
440
|
// Sandbox fight over one path: one call `unlink`ed the file while kern was still starting for
|
|
@@ -1355,6 +1749,7 @@ class Sandbox {
|
|
|
1355
1749
|
* @param {number} [opts.maxOutputBytes] cap on captured stdout/stderr EACH. Default 64 MiB.
|
|
1356
1750
|
* @param {boolean} [opts.enforceLimits] true (default) hard-enforces caps via a systemd scope.
|
|
1357
1751
|
* @param {boolean} [opts.depsReadonly] mount setup= deps read-only for runCode (default true).
|
|
1752
|
+
* @param {boolean} [opts.pycCache] compile this image's stdlib once and mount it read-only (default true).
|
|
1358
1753
|
*/
|
|
1359
1754
|
constructor(opts = {}) {
|
|
1360
1755
|
this.image = opts.image ?? DEFAULT_IMAGE;
|
|
@@ -1413,6 +1808,13 @@ class Sandbox {
|
|
|
1413
1808
|
// must be loaded on the host; kern fails the box CLOSED if it is not. null (default) applies none.
|
|
1414
1809
|
this.apparmor = opts.apparmor ?? null;
|
|
1415
1810
|
this.depsReadonly = opts.depsReadonly ?? true;
|
|
1811
|
+
this.pycCache = opts.pycCache ?? true;
|
|
1812
|
+
/** The cache to mount: "" means this session compiles from source. Set at open() when one is already
|
|
1813
|
+
* there, and by `_pycAdoptIfReady` on the first call after this session's own build publishes one. */
|
|
1814
|
+
this._pycDir = "";
|
|
1815
|
+
/** The destination a build was started for at open(), until it is adopted or refused. Empty whenever
|
|
1816
|
+
* there is nothing to wait for, which is what keeps the check on the call path free. */
|
|
1817
|
+
this._pycPending = "";
|
|
1416
1818
|
// Capabilities dropped from every box this sandbox starts, as kern's own `--cap-drop` takes them.
|
|
1417
1819
|
// The default drops the lot: kern already drops 14 dangerous capabilities unconditionally, but the
|
|
1418
1820
|
// rest were still held over the box's own user namespace, on the one code path whose purpose is
|
|
@@ -1587,6 +1989,44 @@ class Sandbox {
|
|
|
1587
1989
|
}
|
|
1588
1990
|
this._entered = true;
|
|
1589
1991
|
if (this.setup) await this._runSetup(this.setup);
|
|
1992
|
+
// THE BYTECODE CACHE IS DECIDED HERE, once, and frozen: `_baseArgv` is what the prewarm pool
|
|
1993
|
+
// compares postures with, so a cache appearing mid-session would change the argv runCode builds and
|
|
1994
|
+
// every claim would miss. SKIPPED WHEN A SETUP LEFT DEPS: `PYTHONPYCACHEPREFIX` redirects every
|
|
1995
|
+
// lookup, `.deps` included, whose `__pycache__` the setup box fills on purpose (+40 ms per call
|
|
1996
|
+
// without it, measured in `_runSetup`). The two cannot share one prefix - deps are per session, the
|
|
1997
|
+
// cache is per image - and the stdlib win is not worth handing that back.
|
|
1998
|
+
if (this.pycCache && !fs.existsSync(path.join(this._ws, DEPS_DIR))) {
|
|
1999
|
+
const dest = pycDirFor(this.image);
|
|
2000
|
+
// THROUGH THE SAME VALIDATOR AS EVERY OTHER MOUNT: this path comes from $XDG_CACHE_HOME, so a
|
|
2001
|
+
// cache home under a credential directory would otherwise be mounted into every box, and into the
|
|
2002
|
+
// build box writable. A refusal disables the cache rather than throwing - bytecode is an
|
|
2003
|
+
// optimisation and cannot be a reason to fail a session. Mirrors Python.
|
|
2004
|
+
// A refusal means no mount AND no build: there is nothing to produce a cache we may not use.
|
|
2005
|
+
if (pycMountAllowed(dest) && pycPathHasNoSymlink(dest)) {
|
|
2006
|
+
// pycHasContent, not existsSync: an empty directory here is a cache that was swept out
|
|
2007
|
+
// from under a mount and recreated by kern, and adopting it silences the feature for good.
|
|
2008
|
+
if (pycHasContent(dest)) {
|
|
2009
|
+
this._pycDir = dest;
|
|
2010
|
+
// Records the ADOPTION for the sweep's least-recently-used order. Best effort: a cache on a
|
|
2011
|
+
// read-only filesystem is still usable, it just cannot be aged.
|
|
2012
|
+
try {
|
|
2013
|
+
const now = new Date();
|
|
2014
|
+
fs.utimesSync(dest, now, now);
|
|
2015
|
+
} catch {
|
|
2016
|
+
/* not ageable, still usable */
|
|
2017
|
+
}
|
|
2018
|
+
// The bound must hold for processes that never build, which is most of them once the cache
|
|
2019
|
+
// is warm. After the utimes above, so the cache being adopted is the newest thing seen.
|
|
2020
|
+
pycStartSweep(path.dirname(dest));
|
|
2021
|
+
}
|
|
2022
|
+
else {
|
|
2023
|
+
pycStartBuild(this._kern, this.image, dest, Math.max(this.timeoutS, 300));
|
|
2024
|
+
// Remembered so the first call AFTER the build publishes can adopt it. Without this the
|
|
2025
|
+
// session that paid for the build was the one session that never used it.
|
|
2026
|
+
this._pycPending = dest;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
1590
2030
|
// AFTER the setup, deliberately. _baseArgv adds the .deps read-only remount only once that
|
|
1591
2031
|
// directory exists, so a pool filled before the setup ran would hold boxes whose argv no longer
|
|
1592
2032
|
// matches the one runCode builds: every claim would miss and the prewarming would be pure cost.
|
|
@@ -1688,6 +2128,9 @@ class Sandbox {
|
|
|
1688
2128
|
this._kern, "box", name, "--image", this.image, "--ro",
|
|
1689
2129
|
"-v", `${this._ws}:${WORKSPACE}`, "--workdir", WORKSPACE,
|
|
1690
2130
|
];
|
|
2131
|
+
// The image's precompiled stdlib, read-only. Never on the setup box: that one compiles `.deps`
|
|
2132
|
+
// into `__pycache__`, which the prefix would redirect into a mount it cannot write.
|
|
2133
|
+
if (this._pycDir && !isSetup) argv.push("-v", `${this._pycDir}:${PYC_MOUNT}:ro`);
|
|
1691
2134
|
if (this.depsReadonly && !isSetup) {
|
|
1692
2135
|
const deps = path.join(this._ws, DEPS_DIR);
|
|
1693
2136
|
try {
|
|
@@ -1725,6 +2168,10 @@ class Sandbox {
|
|
|
1725
2168
|
|
|
1726
2169
|
const mergedEnv = { ...(this.env || {}) };
|
|
1727
2170
|
if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
|
|
2171
|
+
// Only when the mount exists, and never over a caller's own value: this is an optimisation and
|
|
2172
|
+
// must not overrule an explicit choice.
|
|
2173
|
+
if (this._pycDir && !isSetup && mergedEnv.PYTHONPYCACHEPREFIX === undefined)
|
|
2174
|
+
mergedEnv.PYTHONPYCACHEPREFIX = PYC_MOUNT;
|
|
1728
2175
|
// Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
|
|
1729
2176
|
// `ps` to any local user for the box's lifetime; a credential in env= would leak).
|
|
1730
2177
|
// `_ws` is set by open(); before that it is "". The public API is gated, but the unit tests call
|
|
@@ -1778,7 +2225,48 @@ class Sandbox {
|
|
|
1778
2225
|
return argv;
|
|
1779
2226
|
}
|
|
1780
2227
|
|
|
2228
|
+
/**
|
|
2229
|
+
* Adopt the bytecode cache THIS session's own build produced, on the first call after it lands.
|
|
2230
|
+
*
|
|
2231
|
+
* WHY THIS EXISTS. Adoption used to happen only in open(), which made the session that paid for the
|
|
2232
|
+
* build the one session that never used it: measured on a held-open Sandbox, `_pycDir` stayed empty
|
|
2233
|
+
* for its whole life, seconds after the tree was published, and every call kept compiling from
|
|
2234
|
+
* source. A one-shot call was unaffected because each is its own session. A held-open Sandbox is the
|
|
2235
|
+
* agent loop, which is the shape this package is for.
|
|
2236
|
+
*
|
|
2237
|
+
* WHY THE FREEZE WAS NOT WORTH ITS PRICE. It was defending the prewarm pool: `_baseArgv` is what the
|
|
2238
|
+
* pool compares postures with, so an argv that changes mid-session invalidates every warm box. That
|
|
2239
|
+
* cost is already absorbed - `claim` retires boxes whose key no longer matches and the refill rebuilds
|
|
2240
|
+
* the key from the live argv, the same machinery that handles a `.deps` remount appearing
|
|
2241
|
+
* mid-session. Measured with prewarm=4: ONE call pays the cold path (33 ms against 1.4) and the pool
|
|
2242
|
+
* is full again by the next one, against a whole session that never got the cache.
|
|
2243
|
+
*
|
|
2244
|
+
* ALL THREE GUARDS RUN AGAIN, because open() asked its questions before this tree existed.
|
|
2245
|
+
*
|
|
2246
|
+
* Called before the pool is asked for a box, so the claim that follows compares the new posture.
|
|
2247
|
+
*/
|
|
2248
|
+
_pycAdoptIfReady() {
|
|
2249
|
+
const dest = this._pycPending;
|
|
2250
|
+
// Empty means NOT READY, so the pending destination is kept: either the build has not
|
|
2251
|
+
// published yet, or what is there is the husk kern recreates under a swept mount.
|
|
2252
|
+
if (!dest || !pycHasContent(dest)) return;
|
|
2253
|
+
// Cleared FIRST and unconditionally: a refusal below must not leave this session re-checking a path
|
|
2254
|
+
// it has already rejected on every call it makes.
|
|
2255
|
+
this._pycPending = "";
|
|
2256
|
+
if (fs.existsSync(path.join(this._ws, DEPS_DIR))) return;
|
|
2257
|
+
if (!pycMountAllowed(dest) || !pycPathHasNoSymlink(dest)) return;
|
|
2258
|
+
this._pycDir = dest;
|
|
2259
|
+
try {
|
|
2260
|
+
const now = new Date();
|
|
2261
|
+
fs.utimesSync(dest, now, now); // best effort, as at open(): it only orders the sweep
|
|
2262
|
+
} catch {
|
|
2263
|
+
/* not ageable, still usable */
|
|
2264
|
+
}
|
|
2265
|
+
pycStartSweep(path.dirname(dest));
|
|
2266
|
+
}
|
|
2267
|
+
|
|
1781
2268
|
_spawn(command, { network, timeoutS, isSetup = false, onStdout = UNSET, onStderr = UNSET }) {
|
|
2269
|
+
this._pycAdoptIfReady(); // one property read once there is nothing left to wait for
|
|
1782
2270
|
const cbOut = onStdout === UNSET ? this.onStdout : onStdout;
|
|
1783
2271
|
const cbErr = onStderr === UNSET ? this.onStderr : onStderr;
|
|
1784
2272
|
for (const part of command)
|
|
@@ -2571,6 +3059,9 @@ class Sandbox {
|
|
|
2571
3059
|
const streaming =
|
|
2572
3060
|
(onStdout === UNSET ? this.onStdout : onStdout) !== null ||
|
|
2573
3061
|
(onStderr === UNSET ? this.onStderr : onStderr) !== null;
|
|
3062
|
+
// BEFORE the claim, not after: the claim compares the posture the next box would have, so a cache
|
|
3063
|
+
// adopted here is already in the key and the boxes warmed without it are retired as stale.
|
|
3064
|
+
this._pycAdoptIfReady();
|
|
2574
3065
|
if (this._pool && !streaming && !code.includes("\0")) {
|
|
2575
3066
|
const warm = this._pool.claim({ network: this.network, deadlineS: eff });
|
|
2576
3067
|
if (warm) {
|
|
@@ -3673,5 +4164,22 @@ module.exports = {
|
|
|
3673
4164
|
Result,
|
|
3674
4165
|
SandboxError,
|
|
3675
4166
|
MountRefused,
|
|
4167
|
+
// The bytecode cache's internals, exported for its tests only: the mount flag and the atomic
|
|
4168
|
+
// publish are security properties, and a test that cannot reach them cannot assert them.
|
|
4169
|
+
_PYC_MOUNT: PYC_MOUNT,
|
|
4170
|
+
_pycDirFor: pycDirFor,
|
|
4171
|
+
_pycBuild: pycBuild,
|
|
4172
|
+
_pycSweep: pycSweep,
|
|
4173
|
+
_pycStartSweep: pycStartSweep,
|
|
4174
|
+
// TEST-ONLY. `PYC_SWEPT` is process state by design (one scan per process), which makes any test
|
|
4175
|
+
// of it order-dependent: an earlier test that adopts a cache consumes the single sweep. Python
|
|
4176
|
+
// reaches the same global with monkeypatch; Node needs a setter because a `let` cannot be
|
|
4177
|
+
// reassigned through the exports object.
|
|
4178
|
+
_pycResetSweptForTests: () => {
|
|
4179
|
+
PYC_SWEPT = false;
|
|
4180
|
+
},
|
|
4181
|
+
_ociCanonicalRef: ociCanonicalRef,
|
|
4182
|
+
_PYC_BUILD_CODE: PYC_BUILD_CODE,
|
|
4183
|
+
_pycStartBuild: pycStartBuild,
|
|
3676
4184
|
version: VERSION,
|
|
3677
4185
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
4
4
|
"description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|