kern-sandbox 0.1.11 → 0.1.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 -3
- package/index.js +106 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# kern-sandbox (Node.js / TypeScript)
|
|
2
2
|
|
|
3
|
-
**[kern](https://github.com/getkern/kern)** is a fast, rootless
|
|
4
|
-
|
|
3
|
+
**[kern](https://github.com/getkern/kern)** is a fast, rootless sandbox and virtual resource
|
|
4
|
+
runtime for any workload, including untrusted and AI-generated code: a real, kernel-enforced box
|
|
5
|
+
that starts in **3.4 ms** from an OCI image, out of one **~1.8 MB** binary, with no daemon.
|
|
6
|
+
**kern-sandbox**
|
|
5
7
|
is its Node / TypeScript binding: run untrusted or agent-generated code in a fresh, isolated box, from Node.
|
|
6
8
|
|
|
7
9
|
On npm: [`npm install kern-sandbox`](https://www.npmjs.com/package/kern-sandbox). For Python, the same
|
|
@@ -175,7 +177,7 @@ can still WRITE an artifact to the workspace and `readFile` it if you prefer.
|
|
|
175
177
|
**Warm kernel (kill the interpreter boot).** Each `runCode` starts a **fresh** interpreter, paying the
|
|
176
178
|
CPython boot (~12 ms) every call. When you run many cells that share state (a REPL, a notebook, an
|
|
177
179
|
agent's tool loop), open a `kernel()`: ONE warm interpreter in a long-lived box, fed cells over a pipe.
|
|
178
|
-
In-memory state persists across cells and the per-cell cost drops from ~
|
|
180
|
+
In-memory state persists across cells and the per-cell cost drops from ~14 ms to **sub-millisecond**
|
|
179
181
|
(~300x). Same rich `results` capture as `runCode`.
|
|
180
182
|
|
|
181
183
|
```js
|
package/index.js
CHANGED
|
@@ -36,12 +36,19 @@ const crypto = require("crypto");
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const { spawn, spawnSync } = require("child_process");
|
|
38
38
|
|
|
39
|
-
const VERSION = "0.1.
|
|
39
|
+
const VERSION = "0.1.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
|
|
43
43
|
const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYTHONPATH for python)
|
|
44
44
|
const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
|
|
45
|
+
// One file per CALL, `.kern-env.<box-name>`. A single fixed name made concurrent calls on the same
|
|
46
|
+
// Sandbox fight over one path: one call `unlink`ed the file while kern was still starting for
|
|
47
|
+
// another and had not read it yet, and that box died with
|
|
48
|
+
// error: sandbox: cannot read --env-file '...': No such file or directory
|
|
49
|
+
// Measured at 30 concurrent runCode calls: 2 failed that way, and one file was left behind.
|
|
50
|
+
// The `O_EXCL|O_NOFOLLOW` create is a security property and is unchanged; only the NAME is per-call.
|
|
51
|
+
const ENV_SEP = ".";
|
|
45
52
|
const INLINE_CODE_MAX = 128 * 1024; // above this, pass code via a file instead of argv (ARG_MAX guard)
|
|
46
53
|
// Cap the results file the (untrusted) box writes before the binding reads it into host RAM: a malicious
|
|
47
54
|
// cell could stream a multi-GB `.res` to disk (past its own memory cap) and OOM the host.
|
|
@@ -660,7 +667,9 @@ function tarCollect(dir, base, skip, out) {
|
|
|
660
667
|
if (st.isDirectory()) tarCollect(abs, base, skip, out);
|
|
661
668
|
else if (st.isFile()) {
|
|
662
669
|
const rel = path.relative(base, abs);
|
|
663
|
-
|
|
670
|
+
// `skip` names OUR env file. Since it is now one per call, match the `<skip>.` prefix too, so a
|
|
671
|
+
// file left behind by a process that died mid-call cannot end up inside a user's snapshot.
|
|
672
|
+
if (rel === skip || rel.startsWith(skip + ENV_SEP)) continue;
|
|
664
673
|
tarWriteFile(out, rel.split(path.sep).join("/"), fs.readFileSync(abs));
|
|
665
674
|
}
|
|
666
675
|
}
|
|
@@ -835,6 +844,29 @@ class Sandbox {
|
|
|
835
844
|
|
|
836
845
|
// -- the box invocation --------------------------------------------------------------------------
|
|
837
846
|
|
|
847
|
+
/** Host path of the private --env-file for the box called `name`, inside the workspace. */
|
|
848
|
+
_envPath(name) {
|
|
849
|
+
return path.join(this._ws, `${ENV_FILE}${ENV_SEP}${name}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Is `rel` one of OUR env files rather than user state? Exact-match on the legacy name plus the
|
|
854
|
+
* `.kern-env.` prefix, never a bare startsWith: a user file called `.kern-environment` is theirs
|
|
855
|
+
* and must still show up in `files` and in a snapshot.
|
|
856
|
+
*/
|
|
857
|
+
static _isEnvFile(rel) {
|
|
858
|
+
return rel === ENV_FILE || rel.startsWith(ENV_FILE + ENV_SEP);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Remove this call's env file. Every exit path calls it; a missing file is the desired end state. */
|
|
862
|
+
_removeEnvFile(name) {
|
|
863
|
+
try {
|
|
864
|
+
fs.unlinkSync(this._envPath(name));
|
|
865
|
+
} catch {
|
|
866
|
+
/* ENOENT is fine: no env was passed, or it is already gone */
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
838
870
|
_baseArgv(name, { network, timeoutS, isSetup = false }) {
|
|
839
871
|
const argv = [
|
|
840
872
|
this._kern, "box", name, "--image", this.image, "--ro",
|
|
@@ -867,8 +899,13 @@ class Sandbox {
|
|
|
867
899
|
if (mergedEnv.PYTHONPATH === undefined) mergedEnv.PYTHONPATH = `${WORKSPACE}/${DEPS_DIR}`;
|
|
868
900
|
// Pass env via a private 0600 --env-file, NOT `--env K=V` on argv (an argv value is visible in
|
|
869
901
|
// `ps` to any local user for the box's lifetime; a credential in env= would leak).
|
|
870
|
-
|
|
871
|
-
|
|
902
|
+
// `_ws` is set by open(); before that it is "". The public API is gated, but the unit tests call
|
|
903
|
+
// `_baseArgv` directly to inspect the argv, and with an empty workspace `path.join` yielded a
|
|
904
|
+
// RELATIVE path, so the env file was written into the current directory. Same as the Python side:
|
|
905
|
+
// it had been landing in the repository, hidden by a `.gitignore` line that stopped matching when
|
|
906
|
+
// the name became per-call. No workspace means nowhere to put it.
|
|
907
|
+
if (Object.keys(mergedEnv).length > 0 && this._ws) {
|
|
908
|
+
const envPath = this._envPath(name);
|
|
872
909
|
const lines = [];
|
|
873
910
|
for (const [k, v] of Object.entries(mergedEnv)) {
|
|
874
911
|
const val = String(v);
|
|
@@ -923,6 +960,7 @@ class Sandbox {
|
|
|
923
960
|
stdio: ["ignore", "pipe", "pipe"],
|
|
924
961
|
});
|
|
925
962
|
} catch (e) {
|
|
963
|
+
this._removeEnvFile(name);
|
|
926
964
|
return reject(new SandboxError(`could not spawn the box: ${e.message}`));
|
|
927
965
|
}
|
|
928
966
|
|
|
@@ -930,20 +968,20 @@ class Sandbox {
|
|
|
930
968
|
const err = cappedCollector(child.stderr, this.maxOutputBytes, cbErr);
|
|
931
969
|
let timedOut = false;
|
|
932
970
|
let settled = false;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}, timeoutS * 1000);
|
|
938
|
-
|
|
939
|
-
// Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it; never
|
|
940
|
-
// hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
|
|
971
|
+
// Both timers are armed further down, once `finish` exists. They are declared here, as `let`,
|
|
972
|
+
// so the closures that clear them can never reference a binding in its temporal dead zone
|
|
973
|
+
// whatever the callback ordering turns out to be.
|
|
974
|
+
let timer = null;
|
|
941
975
|
let hardTimer = null;
|
|
976
|
+
|
|
942
977
|
const finish = (code, signal) => {
|
|
943
978
|
if (settled) return;
|
|
944
979
|
settled = true;
|
|
945
|
-
clearTimeout(timer);
|
|
980
|
+
if (timer) clearTimeout(timer);
|
|
946
981
|
if (hardTimer) clearTimeout(hardTimer);
|
|
982
|
+
// kern has read the file by the time it exits; leaving it behind would accrete one per call
|
|
983
|
+
// in a persistent `workspace`.
|
|
984
|
+
this._removeEnvFile(name);
|
|
947
985
|
const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
|
|
948
986
|
const stdout = out.buffer().toString("utf8");
|
|
949
987
|
const stderr = err.buffer().toString("utf8");
|
|
@@ -959,6 +997,7 @@ class Sandbox {
|
|
|
959
997
|
};
|
|
960
998
|
|
|
961
999
|
child.on("error", (e) => {
|
|
1000
|
+
this._removeEnvFile(name);
|
|
962
1001
|
if (e && e.code === "ENOENT")
|
|
963
1002
|
return reject(new SandboxError(`could not execute kern (${argv[0]}): not found`));
|
|
964
1003
|
return reject(new SandboxError(`could not execute kern: ${e.message}`));
|
|
@@ -966,20 +1005,22 @@ class Sandbox {
|
|
|
966
1005
|
child.on("close", (code, signal) => {
|
|
967
1006
|
finish(code, signal);
|
|
968
1007
|
});
|
|
969
|
-
//
|
|
1008
|
+
// Hard safety net: a CPU-bound box can survive our signals until kern's backstop reaps it;
|
|
1009
|
+
// never hang the caller. If close hasn't fired a few seconds after our teardown, resolve anyway.
|
|
970
1010
|
const armHardNet = () => {
|
|
971
1011
|
if (hardTimer) return;
|
|
972
1012
|
hardTimer = setTimeout(() => finish(EXIT_SIGKILL, "SIGKILL"), 10000);
|
|
973
1013
|
};
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1014
|
+
|
|
1015
|
+
timer = setTimeout(() => {
|
|
1016
|
+
timedOut = true;
|
|
1017
|
+
this._teardown(child, name, childEnv);
|
|
1018
|
+
// Armed HERE, at the one place that decides to kill. It used to be noticed instead by a
|
|
1019
|
+
// 250 ms setInterval that `finish` never cleared, so after every call that interval kept the
|
|
1020
|
+
// event loop alive until its own next tick: measured 224 to 232 ms of dead time between a
|
|
1021
|
+
// call resolving and the process being able to exit, against 19 to 27 ms of real work.
|
|
1022
|
+
armHardNet();
|
|
1023
|
+
}, timeoutS * 1000);
|
|
983
1024
|
});
|
|
984
1025
|
}
|
|
985
1026
|
|
|
@@ -1200,7 +1241,7 @@ class Sandbox {
|
|
|
1200
1241
|
snapshot(dest) {
|
|
1201
1242
|
this._requireEntered();
|
|
1202
1243
|
this._requireSnapshotOptIn();
|
|
1203
|
-
fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE));
|
|
1244
|
+
fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE)); // prefix-excluded inside tarPack
|
|
1204
1245
|
}
|
|
1205
1246
|
|
|
1206
1247
|
/** Extract a snapshot (from snapshot()) into the workspace, SAFELY. Every member is vetted first:
|
|
@@ -1316,7 +1357,7 @@ class Sandbox {
|
|
|
1316
1357
|
}
|
|
1317
1358
|
if (!st.isFile()) continue; // excludes symlinks and non-regular files
|
|
1318
1359
|
const rel = path.relative(base, fp);
|
|
1319
|
-
if (rel
|
|
1360
|
+
if (Sandbox._isEnvFile(rel)) continue; // our private host-side env file, not a user artifact
|
|
1320
1361
|
out[rel] = [Math.round(st.mtimeMs * 1e6), st.size];
|
|
1321
1362
|
}
|
|
1322
1363
|
}
|
|
@@ -1595,6 +1636,15 @@ class Kernel {
|
|
|
1595
1636
|
const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
|
|
1596
1637
|
return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
|
|
1597
1638
|
}
|
|
1639
|
+
return this._resultFromReply(reply, started);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Turn one kernel reply into an `ExecutionResult`.
|
|
1643
|
+
*
|
|
1644
|
+
* Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
|
|
1645
|
+
* is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
|
|
1646
|
+
* attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
|
|
1647
|
+
_resultFromReply(reply, started) {
|
|
1598
1648
|
let obj;
|
|
1599
1649
|
try {
|
|
1600
1650
|
obj = JSON.parse(reply);
|
|
@@ -1603,15 +1653,25 @@ class Kernel {
|
|
|
1603
1653
|
}
|
|
1604
1654
|
if (!obj || typeof obj !== "object")
|
|
1605
1655
|
return this._teardownResult("killed", "the kernel sent a non-object reply", started);
|
|
1656
|
+
// `rc` is the ONE field whose absence cannot be defaulted. `success` is
|
|
1657
|
+
// `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
|
|
1658
|
+
// what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
|
|
1659
|
+
// declare its own failed run successful by omitting the field or sending a string. An unusable
|
|
1660
|
+
// status is not a status: it is a protocol violation by the in-box runner, which always emits
|
|
1661
|
+
// `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
|
|
1662
|
+
// boolean, a float and a numeric string, which is what it is here for.
|
|
1663
|
+
if (!Number.isInteger(obj.rc))
|
|
1664
|
+
return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
|
|
1665
|
+
// The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
|
|
1666
|
+
// failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
|
|
1667
|
+
// sent a number.
|
|
1606
1668
|
const results = Array.isArray(obj.results)
|
|
1607
1669
|
? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
|
|
1608
1670
|
: [];
|
|
1609
|
-
// obj is UNTRUSTED (box-controlled JSON): coerce scalars so a non-string stdout / non-int rc can't
|
|
1610
|
-
// crash a caller doing r.stdout.trim() or arithmetic on r.exitCode.
|
|
1611
1671
|
return new ExecutionResult({
|
|
1612
1672
|
stdout: typeof obj.stdout === "string" ? obj.stdout : "",
|
|
1613
1673
|
stderr: typeof obj.stderr === "string" ? obj.stderr : "",
|
|
1614
|
-
exitCode:
|
|
1674
|
+
exitCode: obj.rc,
|
|
1615
1675
|
durationMs: Date.now() - started,
|
|
1616
1676
|
fault: null,
|
|
1617
1677
|
files: [],
|
|
@@ -1665,7 +1725,24 @@ class Kernel {
|
|
|
1665
1725
|
} catch {
|
|
1666
1726
|
/* ignore */
|
|
1667
1727
|
}
|
|
1668
|
-
|
|
1728
|
+
// Wait for the exit EVENT, capped at 150 ms, rather than sleeping 150 ms unconditionally: that
|
|
1729
|
+
// fixed sleep cost 152 ms on every close of a persistent kernel (measured) for a box that
|
|
1730
|
+
// exits in a few. A child that is already gone has emitted `exit` and will not emit it again,
|
|
1731
|
+
// so that case is tested directly instead of waited on.
|
|
1732
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1733
|
+
await new Promise((resolve) => {
|
|
1734
|
+
let t = null;
|
|
1735
|
+
const onExit = () => {
|
|
1736
|
+
if (t !== null) clearTimeout(t);
|
|
1737
|
+
resolve();
|
|
1738
|
+
};
|
|
1739
|
+
t = setTimeout(() => {
|
|
1740
|
+
child.removeListener("exit", onExit);
|
|
1741
|
+
resolve();
|
|
1742
|
+
}, 150);
|
|
1743
|
+
child.once("exit", onExit);
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1669
1746
|
this._kill();
|
|
1670
1747
|
} else {
|
|
1671
1748
|
this._kill();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kern-sandbox",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "kern is a fast, rootless,
|
|
3
|
+
"version": "0.1.13",
|
|
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",
|
|
7
7
|
"kern",
|