kern-sandbox 0.1.11 → 0.1.12
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 +4 -2
- package/index.js +70 -9
- 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.6 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
|
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.12";
|
|
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
|
|
|
@@ -944,6 +982,9 @@ class Sandbox {
|
|
|
944
982
|
settled = true;
|
|
945
983
|
clearTimeout(timer);
|
|
946
984
|
if (hardTimer) clearTimeout(hardTimer);
|
|
985
|
+
// kern has read the file by the time it exits; leaving it behind would accrete one per call
|
|
986
|
+
// in a persistent `workspace`.
|
|
987
|
+
this._removeEnvFile(name);
|
|
947
988
|
const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
|
|
948
989
|
const stdout = out.buffer().toString("utf8");
|
|
949
990
|
const stderr = err.buffer().toString("utf8");
|
|
@@ -959,6 +1000,7 @@ class Sandbox {
|
|
|
959
1000
|
};
|
|
960
1001
|
|
|
961
1002
|
child.on("error", (e) => {
|
|
1003
|
+
this._removeEnvFile(name);
|
|
962
1004
|
if (e && e.code === "ENOENT")
|
|
963
1005
|
return reject(new SandboxError(`could not execute kern (${argv[0]}): not found`));
|
|
964
1006
|
return reject(new SandboxError(`could not execute kern: ${e.message}`));
|
|
@@ -1200,7 +1242,7 @@ class Sandbox {
|
|
|
1200
1242
|
snapshot(dest) {
|
|
1201
1243
|
this._requireEntered();
|
|
1202
1244
|
this._requireSnapshotOptIn();
|
|
1203
|
-
fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE));
|
|
1245
|
+
fs.writeFileSync(dest, tarPack(fs.realpathSync(this._ws), ENV_FILE)); // prefix-excluded inside tarPack
|
|
1204
1246
|
}
|
|
1205
1247
|
|
|
1206
1248
|
/** Extract a snapshot (from snapshot()) into the workspace, SAFELY. Every member is vetted first:
|
|
@@ -1316,7 +1358,7 @@ class Sandbox {
|
|
|
1316
1358
|
}
|
|
1317
1359
|
if (!st.isFile()) continue; // excludes symlinks and non-regular files
|
|
1318
1360
|
const rel = path.relative(base, fp);
|
|
1319
|
-
if (rel
|
|
1361
|
+
if (Sandbox._isEnvFile(rel)) continue; // our private host-side env file, not a user artifact
|
|
1320
1362
|
out[rel] = [Math.round(st.mtimeMs * 1e6), st.size];
|
|
1321
1363
|
}
|
|
1322
1364
|
}
|
|
@@ -1595,6 +1637,15 @@ class Kernel {
|
|
|
1595
1637
|
const kind = looksLikeStartupFailure(err) ? "startup_failed" : "killed";
|
|
1596
1638
|
return this._teardownResult(kind, err.trim() || "the kernel box exited", started);
|
|
1597
1639
|
}
|
|
1640
|
+
return this._resultFromReply(reply, started);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/** Turn one kernel reply into an `ExecutionResult`.
|
|
1644
|
+
*
|
|
1645
|
+
* Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
|
|
1646
|
+
* is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
|
|
1647
|
+
* attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
|
|
1648
|
+
_resultFromReply(reply, started) {
|
|
1598
1649
|
let obj;
|
|
1599
1650
|
try {
|
|
1600
1651
|
obj = JSON.parse(reply);
|
|
@@ -1603,15 +1654,25 @@ class Kernel {
|
|
|
1603
1654
|
}
|
|
1604
1655
|
if (!obj || typeof obj !== "object")
|
|
1605
1656
|
return this._teardownResult("killed", "the kernel sent a non-object reply", started);
|
|
1657
|
+
// `rc` is the ONE field whose absence cannot be defaulted. `success` is
|
|
1658
|
+
// `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
|
|
1659
|
+
// what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
|
|
1660
|
+
// declare its own failed run successful by omitting the field or sending a string. An unusable
|
|
1661
|
+
// status is not a status: it is a protocol violation by the in-box runner, which always emits
|
|
1662
|
+
// `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
|
|
1663
|
+
// boolean, a float and a numeric string, which is what it is here for.
|
|
1664
|
+
if (!Number.isInteger(obj.rc))
|
|
1665
|
+
return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
|
|
1666
|
+
// The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
|
|
1667
|
+
// failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
|
|
1668
|
+
// sent a number.
|
|
1606
1669
|
const results = Array.isArray(obj.results)
|
|
1607
1670
|
? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
|
|
1608
1671
|
: [];
|
|
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
1672
|
return new ExecutionResult({
|
|
1612
1673
|
stdout: typeof obj.stdout === "string" ? obj.stdout : "",
|
|
1613
1674
|
stderr: typeof obj.stderr === "string" ? obj.stderr : "",
|
|
1614
|
-
exitCode:
|
|
1675
|
+
exitCode: obj.rc,
|
|
1615
1676
|
durationMs: Date.now() - started,
|
|
1616
1677
|
fault: null,
|
|
1617
1678
|
files: [],
|
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.12",
|
|
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",
|