kern-sandbox 0.1.3 → 0.1.5

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 (3) hide show
  1. package/index.d.ts +1 -1
  2. package/index.js +59 -16
  3. package/package.json +4 -2
package/index.d.ts CHANGED
@@ -130,7 +130,7 @@ export class Sandbox {
130
130
  /** Write data to a workspace-relative path (host-direct, O_NOFOLLOW on the final component). */
131
131
  writeFile(path: string, data: Buffer | string): Promise<void>;
132
132
  /** Read a workspace-relative path (host-direct, O_NOFOLLOW). */
133
- readFile(path: string): Promise<Buffer>;
133
+ readFile(path: string, opts?: { maxBytes?: number }): Promise<Buffer>;
134
134
  /** Write a gzip tar of the whole workspace to `dest`, a portable filesystem checkpoint (NOT memory). */
135
135
  snapshot(dest: string): void;
136
136
  /** Extract a snapshot (from snapshot()) into the workspace, safely (rejects symlink/.. /absolute members). */
package/index.js CHANGED
@@ -36,24 +36,48 @@ 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.3";
39
+ const VERSION = "0.1.5";
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
46
49
 
47
50
  // Python cell runner (P1: rich mime-typed results, Jupyter/E2B-style, no Jupyter kernel). Runs INSIDE
48
51
  // the box (it is Python, regardless of which binding drove it): execs the user cell, then captures the
49
52
  // trailing bare expression's value, every display(obj) call, and every open matplotlib figure, writing
50
- // them as a JSON mime-bundle list the binding reads back. stdout/stderr/exit are UNTOUCHED. Kept byte-
51
- // identical to the Python binding's runner. __KERN_CELL__/__KERN_RES__ are substituted per call.
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.
52
56
  const PY_RUNNER = `
53
- import sys, os, json, base64, io, ast, traceback, builtins
57
+ import sys, builtins # C builtins: no .py to recompile in the read-only slim box (the P1 hot path).
54
58
  _CELL = "__KERN_CELL__"
55
59
  _RES = "__KERN_RES__"
56
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)
57
81
  def _bundle(o):
58
82
  d = {}
59
83
  for meth, key in (("_repr_html_", "text/html"), ("_repr_markdown_", "text/markdown"),
@@ -71,7 +95,11 @@ def _bundle(o):
71
95
  if callable(fn):
72
96
  v = fn()
73
97
  if v is not None:
74
- d["application/json"] = v if isinstance(v, str) else json.dumps(v)
98
+ if isinstance(v, str):
99
+ d["application/json"] = v
100
+ else:
101
+ import json
102
+ d["application/json"] = json.dumps(v)
75
103
  except Exception:
76
104
  pass
77
105
  for meth, key in (("_repr_png_", "image/png"), ("_repr_jpeg_", "image/jpeg")):
@@ -80,6 +108,7 @@ def _bundle(o):
80
108
  if callable(fn):
81
109
  v = fn()
82
110
  if v:
111
+ import base64
83
112
  raw = v if isinstance(v, (bytes, bytearray)) else str(v).encode()
84
113
  d[key] = base64.b64encode(raw).decode()
85
114
  except Exception:
@@ -99,18 +128,27 @@ _g = {"__name__": "__main__", "__file__": _CELL, "display": display}
99
128
  _rc = 0
100
129
  try:
101
130
  _src = open(_CELL, "r", encoding="utf-8").read()
102
- _tree = ast.parse(_src, _CELL, "exec")
131
+ _tree = compile(_src, _CELL, "exec", 0x400)
103
132
  _tail = None
104
- if _tree.body and isinstance(_tree.body[-1], ast.Expr):
105
- _tail = _tree.body.pop().value
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)
106
143
  exec(compile(_tree, _CELL, "exec"), _g)
107
144
  if _tail is not None:
108
- _val = eval(compile(ast.Expression(_tail), _CELL, "eval"), _g)
145
+ _val = eval(compile(_tail, _CELL, "eval"), _g)
109
146
  if _val is not None:
110
147
  _out.append(_bundle(_val))
111
148
  except SystemExit as _e:
112
149
  _rc = _e.code if isinstance(_e.code, int) else (0 if _e.code is None else 1)
113
150
  except BaseException as _e:
151
+ import traceback
114
152
  _tb = _e.__traceback__
115
153
  while _tb is not None and _tb.tb_frame.f_code.co_filename != _CELL:
116
154
  _tb = _tb.tb_next
@@ -118,16 +156,17 @@ except BaseException as _e:
118
156
  _rc = 1
119
157
  try:
120
158
  if "matplotlib.pyplot" in sys.modules:
159
+ import base64, io
121
160
  _plt = sys.modules["matplotlib.pyplot"]
122
- for _n in _plt.get_fignums():
161
+ for _fig in _plt.get_fignums():
123
162
  _buf = io.BytesIO()
124
- _plt.figure(_n).savefig(_buf, format="png")
163
+ _plt.figure(_fig).savefig(_buf, format="png")
125
164
  _out.append({"image/png": base64.b64encode(_buf.getvalue()).decode()})
126
165
  except Exception:
127
166
  pass
128
167
  try:
129
- with open(_RES, "w", encoding="utf-8") as _fh:
130
- json.dump(_out, _fh)
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) + "]")
131
170
  except Exception:
132
171
  pass
133
172
  sys.exit(_rc)
@@ -851,7 +890,7 @@ class Sandbox {
851
890
  }
852
891
 
853
892
  /** Read `path` (workspace-relative) from the workspace - host-direct. Final component O_NOFOLLOW. */
854
- async readFile(rel) {
893
+ async readFile(rel, { maxBytes = null } = {}) {
855
894
  this._requireEntered();
856
895
  const full = this._wsPath(rel);
857
896
  let fd;
@@ -861,6 +900,10 @@ class Sandbox {
861
900
  throw new SandboxError(`cannot read ${JSON.stringify(rel)}: ${e.message}`);
862
901
  }
863
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}`);
864
907
  return fs.readFileSync(fd);
865
908
  } finally {
866
909
  fs.closeSync(fd);
@@ -1078,11 +1121,11 @@ class Sandbox {
1078
1121
  timeoutS: this.timeoutS,
1079
1122
  });
1080
1123
  try {
1081
- const parsed = JSON.parse(await this.readFile(resf));
1124
+ const parsed = JSON.parse(await this.readFile(resf, { maxBytes: RESULTS_MAX }));
1082
1125
  if (Array.isArray(parsed))
1083
1126
  result.results = parsed.filter((r) => r && typeof r === "object").map((r) => new Result(r));
1084
1127
  } catch {
1085
- /* no results file / unreadable / bad JSON: leave results empty, run otherwise intact */
1128
+ /* missing / too-large / unreadable / bad JSON: leave results empty, run otherwise intact */
1086
1129
  }
1087
1130
  const internal = new Set([cell, resf, runf]);
1088
1131
  for (const name of internal) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.3",
4
- "description": "Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox. A thin, dependency-free wrapper around the kern binary.",
3
+ "version": "0.1.5",
4
+ "description": "A fast, local, daemonless code-interpreter sandbox for agent/LLM code: run Python/JS/Bash, get rich results (charts, tables), no cloud/account/VM. A thin, dependency-free wrapper around the kern binary.",
5
5
  "keywords": [
6
6
  "sandbox",
7
7
  "kern",
@@ -11,6 +11,8 @@
11
11
  "isolation",
12
12
  "seccomp",
13
13
  "container",
14
+ "code-interpreter",
15
+ "jupyter",
14
16
  "e2b"
15
17
  ],
16
18
  "homepage": "https://github.com/getkern/kern/tree/main/bindings/node",