kern-sandbox 0.1.3 → 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/index.js +59 -16
- package/package.json +1 -1
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.
|
|
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
|
|
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.
|
|
51
|
-
//
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
131
|
+
_tree = compile(_src, _CELL, "exec", 0x400)
|
|
103
132
|
_tail = None
|
|
104
|
-
if _tree.body and
|
|
105
|
-
|
|
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(
|
|
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
|
|
161
|
+
for _fig in _plt.get_fignums():
|
|
123
162
|
_buf = io.BytesIO()
|
|
124
|
-
_plt.figure(
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
/*
|
|
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