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.
Files changed (4) hide show
  1. package/README.md +17 -7
  2. package/index.d.ts +25 -0
  3. package/index.js +214 -3
  4. 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
- **Charts / artifacts (the "code interpreter" pattern).** No Jupyter kernel: have the code WRITE the
140
- artifact to the workspace, then read it back with `readFile`.
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]); plt.savefig('chart.png')");
146
- const png = await sbx.readFile("chart.png"); // Buffer, ready to return to the model / user
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.2";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox. A thin, dependency-free wrapper around the kern binary.",
5
5
  "keywords": [
6
6
  "sandbox",