kern-sandbox 0.1.2 → 0.1.4
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 +17 -7
- package/index.d.ts +25 -0
- package/index.js +214 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,7 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
|
|
|
77
77
|
| `success` | `true` iff `exitCode === 0` **and** no sandbox fault |
|
|
78
78
|
| `fault` | a sandbox event, or `null`. `{ type, message }` |
|
|
79
79
|
| `files` | files created/modified in the workspace this call |
|
|
80
|
+
| `results` | rich mime-typed values (`Result[]`): last expression, `display()`, matplotlib figures |
|
|
80
81
|
| `truncated` | output hit the cap and overflow was discarded |
|
|
81
82
|
|
|
82
83
|
A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it is a normal result.
|
|
@@ -134,19 +135,28 @@ new Sandbox({
|
|
|
134
135
|
});
|
|
135
136
|
```
|
|
136
137
|
|
|
137
|
-
## Charts, live output, and checkpoints
|
|
138
|
+
## Charts, rich results, live output, and checkpoints
|
|
138
139
|
|
|
139
|
-
**
|
|
140
|
-
|
|
140
|
+
**Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
|
|
141
|
+
Jupyter/E2B cell it captures rich, mime-typed values into `result.results` (a list of `Result`) with
|
|
142
|
+
**no Jupyter kernel**: the value of the code's **last bare expression**, every **`display(obj)`** call,
|
|
143
|
+
and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.png`/`.jpeg` (Buffer),
|
|
144
|
+
`.html`, `.svg`, `.markdown`, `.json`, `.text`.
|
|
141
145
|
|
|
142
146
|
```js
|
|
143
|
-
await kern.withSandbox({ setup: "pip install matplotlib" }, async (sbx) => {
|
|
144
|
-
await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
145
|
-
"import matplotlib.pyplot as plt; plt.plot([1,4,9])
|
|
146
|
-
const png =
|
|
147
|
+
await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) => {
|
|
148
|
+
let r = await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
149
|
+
"import matplotlib.pyplot as plt; plt.plot([1,4,9])");
|
|
150
|
+
const png = r.results[0].png; // Buffer of the figure, auto-captured
|
|
151
|
+
|
|
152
|
+
r = await sbx.runCode("import pandas as pd; pd.DataFrame({'a':[1,2]})");
|
|
153
|
+
r.results[0].html; // the DataFrame as an HTML table (also .text)
|
|
147
154
|
});
|
|
148
155
|
```
|
|
149
156
|
|
|
157
|
+
Capture never touches `stdout`/`stderr`/`exitCode`; a statement returning `None` yields no result. You
|
|
158
|
+
can still WRITE an artifact to the workspace and `readFile` it if you prefer.
|
|
159
|
+
|
|
150
160
|
**Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
|
|
151
161
|
best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
|
|
152
162
|
(the full capped output is always in `result.stdout`).
|
package/index.d.ts
CHANGED
|
@@ -17,6 +17,29 @@ export interface FileInfo {
|
|
|
17
17
|
change: "created" | "modified";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** A rich, mime-typed value captured from a Python `runCode` (Jupyter/E2B-style): the code's last bare
|
|
21
|
+
* expression, every `display(obj)` call, and every open matplotlib figure. `data` maps a MIME type to
|
|
22
|
+
* its payload (text/* and application/json are strings; image/* are base64). One value, several forms. */
|
|
23
|
+
export class Result {
|
|
24
|
+
data: Record<string, string>;
|
|
25
|
+
/** text/plain */
|
|
26
|
+
readonly text?: string;
|
|
27
|
+
/** text/html */
|
|
28
|
+
readonly html?: string;
|
|
29
|
+
/** text/markdown */
|
|
30
|
+
readonly markdown?: string;
|
|
31
|
+
/** image/svg+xml */
|
|
32
|
+
readonly svg?: string;
|
|
33
|
+
/** application/json */
|
|
34
|
+
readonly json?: string;
|
|
35
|
+
/** image/png decoded to a Buffer, or null */
|
|
36
|
+
readonly png: Buffer | null;
|
|
37
|
+
/** image/jpeg decoded to a Buffer, or null */
|
|
38
|
+
readonly jpeg: Buffer | null;
|
|
39
|
+
/** The MIME types this value was captured as. */
|
|
40
|
+
formats(): string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the sandbox act";
|
|
21
44
|
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
22
45
|
export class ExecutionResult {
|
|
@@ -29,6 +52,8 @@ export class ExecutionResult {
|
|
|
29
52
|
files: FileInfo[];
|
|
30
53
|
/** stdout/stderr hit the capture cap and overflow was discarded. */
|
|
31
54
|
truncated: boolean;
|
|
55
|
+
/** Rich mime-typed values (Python runCode): last expression, display(), matplotlib figures. */
|
|
56
|
+
results: Result[];
|
|
32
57
|
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
33
58
|
readonly success: boolean;
|
|
34
59
|
}
|
package/index.js
CHANGED
|
@@ -36,13 +36,141 @@ 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.4";
|
|
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
45
|
const INLINE_CODE_MAX = 128 * 1024; // above this, pass code via a file instead of argv (ARG_MAX guard)
|
|
46
|
+
// Cap the results file the (untrusted) box writes before the binding reads it into host RAM: a malicious
|
|
47
|
+
// cell could stream a multi-GB `.res` to disk (past its own memory cap) and OOM the host.
|
|
48
|
+
const RESULTS_MAX = 64 * 1024 * 1024; // 64 MiB: generous for charts/tables, bounds the attacker read
|
|
49
|
+
|
|
50
|
+
// Python cell runner (P1: rich mime-typed results, Jupyter/E2B-style, no Jupyter kernel). Runs INSIDE
|
|
51
|
+
// the box (it is Python, regardless of which binding drove it): execs the user cell, then captures the
|
|
52
|
+
// trailing bare expression's value, every display(obj) call, and every open matplotlib figure, writing
|
|
53
|
+
// them as a JSON mime-bundle list the binding reads back. stdout/stderr/exit are UNTOUCHED. On the hot
|
|
54
|
+
// path it imports only C builtins (no .py to recompile in the read-only slim box); base64/io/traceback/
|
|
55
|
+
// json are lazy. Mirrors the Python binding's runner. __KERN_CELL__/__KERN_RES__ are substituted per call.
|
|
56
|
+
const PY_RUNNER = `
|
|
57
|
+
import sys, builtins # C builtins: no .py to recompile in the read-only slim box (the P1 hot path).
|
|
58
|
+
_CELL = "__KERN_CELL__"
|
|
59
|
+
_RES = "__KERN_RES__"
|
|
60
|
+
_out = []
|
|
61
|
+
def _js(s): # minimal JSON string encoder, so the box needs no \`import json\` (~80ms in a pyc-less slim box)
|
|
62
|
+
r = ['"']
|
|
63
|
+
for ch in s:
|
|
64
|
+
o = ord(ch)
|
|
65
|
+
if ch == '"':
|
|
66
|
+
r.append('\\\\"')
|
|
67
|
+
elif ch == '\\\\':
|
|
68
|
+
r.append('\\\\\\\\')
|
|
69
|
+
elif o == 10:
|
|
70
|
+
r.append('\\\\n')
|
|
71
|
+
elif o == 13:
|
|
72
|
+
r.append('\\\\r')
|
|
73
|
+
elif o == 9:
|
|
74
|
+
r.append('\\\\t')
|
|
75
|
+
elif o < 32:
|
|
76
|
+
r.append('\\\\u%04x' % o)
|
|
77
|
+
else:
|
|
78
|
+
r.append(ch)
|
|
79
|
+
r.append('"')
|
|
80
|
+
return "".join(r)
|
|
81
|
+
def _bundle(o):
|
|
82
|
+
d = {}
|
|
83
|
+
for meth, key in (("_repr_html_", "text/html"), ("_repr_markdown_", "text/markdown"),
|
|
84
|
+
("_repr_svg_", "image/svg+xml"), ("_repr_latex_", "text/latex")):
|
|
85
|
+
try:
|
|
86
|
+
fn = getattr(o, meth, None)
|
|
87
|
+
if callable(fn):
|
|
88
|
+
v = fn()
|
|
89
|
+
if isinstance(v, str) and v:
|
|
90
|
+
d[key] = v
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
try:
|
|
94
|
+
fn = getattr(o, "_repr_json_", None)
|
|
95
|
+
if callable(fn):
|
|
96
|
+
v = fn()
|
|
97
|
+
if v is not None:
|
|
98
|
+
if isinstance(v, str):
|
|
99
|
+
d["application/json"] = v
|
|
100
|
+
else:
|
|
101
|
+
import json
|
|
102
|
+
d["application/json"] = json.dumps(v)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
for meth, key in (("_repr_png_", "image/png"), ("_repr_jpeg_", "image/jpeg")):
|
|
106
|
+
try:
|
|
107
|
+
fn = getattr(o, meth, None)
|
|
108
|
+
if callable(fn):
|
|
109
|
+
v = fn()
|
|
110
|
+
if v:
|
|
111
|
+
import base64
|
|
112
|
+
raw = v if isinstance(v, (bytes, bytearray)) else str(v).encode()
|
|
113
|
+
d[key] = base64.b64encode(raw).decode()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
if "text/plain" not in d:
|
|
117
|
+
try:
|
|
118
|
+
d["text/plain"] = repr(o)
|
|
119
|
+
except Exception:
|
|
120
|
+
d["text/plain"] = "<unrepresentable>"
|
|
121
|
+
return d
|
|
122
|
+
def display(o=None, **kw):
|
|
123
|
+
if o is not None:
|
|
124
|
+
_out.append(_bundle(o))
|
|
125
|
+
builtins.display = display
|
|
126
|
+
sys.argv = [_CELL]
|
|
127
|
+
_g = {"__name__": "__main__", "__file__": _CELL, "display": display}
|
|
128
|
+
_rc = 0
|
|
129
|
+
try:
|
|
130
|
+
_src = open(_CELL, "r", encoding="utf-8").read()
|
|
131
|
+
_tree = compile(_src, _CELL, "exec", 0x400)
|
|
132
|
+
_tail = None
|
|
133
|
+
if _tree.body and type(_tree.body[-1]).__name__ == "Expr":
|
|
134
|
+
_n = _tree.body.pop()
|
|
135
|
+
_lines = _src.split("\\n")
|
|
136
|
+
if _n.lineno == _n.end_lineno:
|
|
137
|
+
_tail = _lines[_n.lineno - 1].encode()[_n.col_offset:_n.end_col_offset].decode("utf-8", "replace")
|
|
138
|
+
else:
|
|
139
|
+
_seg = [_lines[_n.lineno - 1].encode()[_n.col_offset:].decode("utf-8", "replace")]
|
|
140
|
+
_seg += _lines[_n.lineno:_n.end_lineno - 1]
|
|
141
|
+
_seg.append(_lines[_n.end_lineno - 1].encode()[:_n.end_col_offset].decode("utf-8", "replace"))
|
|
142
|
+
_tail = "\\n".join(_seg)
|
|
143
|
+
exec(compile(_tree, _CELL, "exec"), _g)
|
|
144
|
+
if _tail is not None:
|
|
145
|
+
_val = eval(compile(_tail, _CELL, "eval"), _g)
|
|
146
|
+
if _val is not None:
|
|
147
|
+
_out.append(_bundle(_val))
|
|
148
|
+
except SystemExit as _e:
|
|
149
|
+
_rc = _e.code if isinstance(_e.code, int) else (0 if _e.code is None else 1)
|
|
150
|
+
except BaseException as _e:
|
|
151
|
+
import traceback
|
|
152
|
+
_tb = _e.__traceback__
|
|
153
|
+
while _tb is not None and _tb.tb_frame.f_code.co_filename != _CELL:
|
|
154
|
+
_tb = _tb.tb_next
|
|
155
|
+
sys.stderr.write("".join(traceback.format_exception(type(_e), _e, _tb)))
|
|
156
|
+
_rc = 1
|
|
157
|
+
try:
|
|
158
|
+
if "matplotlib.pyplot" in sys.modules:
|
|
159
|
+
import base64, io
|
|
160
|
+
_plt = sys.modules["matplotlib.pyplot"]
|
|
161
|
+
for _fig in _plt.get_fignums():
|
|
162
|
+
_buf = io.BytesIO()
|
|
163
|
+
_plt.figure(_fig).savefig(_buf, format="png")
|
|
164
|
+
_out.append({"image/png": base64.b64encode(_buf.getvalue()).decode()})
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
_parts = ["{" + ",".join(_js(str(_k)) + ":" + _js(str(_v)) for _k, _v in _d.items()) + "}" for _d in _out]
|
|
169
|
+
open(_RES, "w", encoding="utf-8").write("[" + ",".join(_parts) + "]")
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
sys.exit(_rc)
|
|
173
|
+
`;
|
|
46
174
|
|
|
47
175
|
// Signal-derived exit codes (128 + signum) we classify.
|
|
48
176
|
const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
|
|
@@ -80,10 +208,47 @@ class MountRefused extends SandboxError {
|
|
|
80
208
|
}
|
|
81
209
|
}
|
|
82
210
|
|
|
211
|
+
/** A rich, mime-typed value captured from a Python `runCode` (the way a Jupyter/E2B cell captures
|
|
212
|
+
* output): the value of the code's last bare expression, every `display(obj)` call, and every open
|
|
213
|
+
* matplotlib figure. `data` maps a MIME type to its payload; text/* and application/json are strings,
|
|
214
|
+
* image/* are base64 strings (use `.png`/`.jpeg` for Buffers). One value can carry several forms. */
|
|
215
|
+
class Result {
|
|
216
|
+
constructor(data) {
|
|
217
|
+
this.data = data || {};
|
|
218
|
+
}
|
|
219
|
+
get text() {
|
|
220
|
+
return this.data["text/plain"];
|
|
221
|
+
}
|
|
222
|
+
get html() {
|
|
223
|
+
return this.data["text/html"];
|
|
224
|
+
}
|
|
225
|
+
get markdown() {
|
|
226
|
+
return this.data["text/markdown"];
|
|
227
|
+
}
|
|
228
|
+
get svg() {
|
|
229
|
+
return this.data["image/svg+xml"];
|
|
230
|
+
}
|
|
231
|
+
get json() {
|
|
232
|
+
return this.data["application/json"];
|
|
233
|
+
}
|
|
234
|
+
get png() {
|
|
235
|
+
const v = this.data["image/png"];
|
|
236
|
+
return v ? Buffer.from(v, "base64") : null;
|
|
237
|
+
}
|
|
238
|
+
get jpeg() {
|
|
239
|
+
const v = this.data["image/jpeg"];
|
|
240
|
+
return v ? Buffer.from(v, "base64") : null;
|
|
241
|
+
}
|
|
242
|
+
/** The MIME types this value was captured as. */
|
|
243
|
+
formats() {
|
|
244
|
+
return Object.keys(this.data);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
83
248
|
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the SANDBOX act";
|
|
84
249
|
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
85
250
|
class ExecutionResult {
|
|
86
|
-
constructor({ stdout, stderr, exitCode, durationMs, fault, files, truncated }) {
|
|
251
|
+
constructor({ stdout, stderr, exitCode, durationMs, fault, files, truncated, results }) {
|
|
87
252
|
this.stdout = stdout;
|
|
88
253
|
this.stderr = stderr;
|
|
89
254
|
this.exitCode = exitCode;
|
|
@@ -92,6 +257,8 @@ class ExecutionResult {
|
|
|
92
257
|
this.fault = fault || null;
|
|
93
258
|
this.files = files || [];
|
|
94
259
|
this.truncated = !!truncated;
|
|
260
|
+
/** @type {Result[]} rich mime-typed values (Python runCode) */
|
|
261
|
+
this.results = results || [];
|
|
95
262
|
}
|
|
96
263
|
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
97
264
|
get success() {
|
|
@@ -723,7 +890,7 @@ class Sandbox {
|
|
|
723
890
|
}
|
|
724
891
|
|
|
725
892
|
/** Read `path` (workspace-relative) from the workspace - host-direct. Final component O_NOFOLLOW. */
|
|
726
|
-
async readFile(rel) {
|
|
893
|
+
async readFile(rel, { maxBytes = null } = {}) {
|
|
727
894
|
this._requireEntered();
|
|
728
895
|
const full = this._wsPath(rel);
|
|
729
896
|
let fd;
|
|
@@ -733,6 +900,10 @@ class Sandbox {
|
|
|
733
900
|
throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
|
|
734
901
|
}
|
|
735
902
|
try {
|
|
903
|
+
// maxBytes caps the read so a file a not-fully-trusted box wrote can't OOM the host; the box has
|
|
904
|
+
// already exited by the time we read, so fstat -> read is race-free.
|
|
905
|
+
if (maxBytes !== null && fs.fstatSync(fd).size > maxBytes)
|
|
906
|
+
throw new SandboxError(`${JSON.stringify(rel)} exceeds maxBytes=${maxBytes}`);
|
|
736
907
|
return fs.readFileSync(fd);
|
|
737
908
|
} finally {
|
|
738
909
|
fs.closeSync(fd);
|
|
@@ -918,6 +1089,7 @@ class Sandbox {
|
|
|
918
1089
|
if (!spec)
|
|
919
1090
|
throw new SandboxError(`unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'node')`);
|
|
920
1091
|
const [runner, evalFlag, ext] = spec;
|
|
1092
|
+
if (language === "python") return this._runPythonCell(code);
|
|
921
1093
|
let command;
|
|
922
1094
|
if (Buffer.byteLength(code, "utf8") > INLINE_CODE_MAX) {
|
|
923
1095
|
const cell = `.cell-${crypto.randomBytes(4).toString("hex")}.${ext}`;
|
|
@@ -929,6 +1101,44 @@ class Sandbox {
|
|
|
929
1101
|
return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
|
|
930
1102
|
}
|
|
931
1103
|
|
|
1104
|
+
/** Run Python through the cell runner so a trailing expression, display() calls and matplotlib
|
|
1105
|
+
* figures are captured as rich mime-typed `result.results` (Jupyter/E2B-style). stdout/stderr/exit
|
|
1106
|
+
* are identical to a plain run; capture is best-effort. Internal cell/runner/results files are
|
|
1107
|
+
* removed and hidden from `result.files`. */
|
|
1108
|
+
async _runPythonCell(code) {
|
|
1109
|
+
const uid = crypto.randomBytes(4).toString("hex");
|
|
1110
|
+
const cell = `.cell-${uid}.py`;
|
|
1111
|
+
const resf = `.res-${uid}.json`;
|
|
1112
|
+
const runf = `.run-${uid}.py`;
|
|
1113
|
+
await this.writeFile(cell, code);
|
|
1114
|
+
const shim = PY_RUNNER.replace("__KERN_CELL__", `${WORKSPACE}/${cell}`).replace(
|
|
1115
|
+
"__KERN_RES__",
|
|
1116
|
+
`${WORKSPACE}/${resf}`,
|
|
1117
|
+
);
|
|
1118
|
+
await this.writeFile(runf, shim);
|
|
1119
|
+
const result = await this._spawn(["python3", `${WORKSPACE}/${runf}`], {
|
|
1120
|
+
network: this.network,
|
|
1121
|
+
timeoutS: this.timeoutS,
|
|
1122
|
+
});
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(await this.readFile(resf, { maxBytes: RESULTS_MAX }));
|
|
1125
|
+
if (Array.isArray(parsed))
|
|
1126
|
+
result.results = parsed.filter((r) => r && typeof r === "object").map((r) => new Result(r));
|
|
1127
|
+
} catch {
|
|
1128
|
+
/* missing / too-large / unreadable / bad JSON: leave results empty, run otherwise intact */
|
|
1129
|
+
}
|
|
1130
|
+
const internal = new Set([cell, resf, runf]);
|
|
1131
|
+
for (const name of internal) {
|
|
1132
|
+
try {
|
|
1133
|
+
fs.unlinkSync(path.join(this._ws, name));
|
|
1134
|
+
} catch {
|
|
1135
|
+
/* ignore */
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
result.files = result.files.filter((fi) => !internal.has(fi.path));
|
|
1139
|
+
return result;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
932
1142
|
/** Run an arbitrary `command` (an argv ARRAY, never a shell string) in a fresh box. */
|
|
933
1143
|
async run(command) {
|
|
934
1144
|
this._requireEntered();
|
|
@@ -968,6 +1178,7 @@ module.exports = {
|
|
|
968
1178
|
withSandbox,
|
|
969
1179
|
runCode,
|
|
970
1180
|
ExecutionResult,
|
|
1181
|
+
Result,
|
|
971
1182
|
SandboxError,
|
|
972
1183
|
MountRefused,
|
|
973
1184
|
version: VERSION,
|
package/package.json
CHANGED