@draht/rlm 2026.7.12-1
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 +167 -0
- package/dist/cost-comparison.d.ts +81 -0
- package/dist/cost-comparison.d.ts.map +1 -0
- package/dist/cost-comparison.js +93 -0
- package/dist/cost-comparison.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders.d.ts +44 -0
- package/dist/loaders.d.ts.map +1 -0
- package/dist/loaders.js +156 -0
- package/dist/loaders.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +71 -0
- package/dist/prompts.js.map +1 -0
- package/dist/router-session.d.ts +50 -0
- package/dist/router-session.d.ts.map +1 -0
- package/dist/router-session.js +251 -0
- package/dist/router-session.js.map +1 -0
- package/dist/sandbox.d.ts +95 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +273 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +803 -0
- package/dist/session.js.map +1 -0
- package/dist/trajectory.d.ts +71 -0
- package/dist/trajectory.d.ts.map +1 -0
- package/dist/trajectory.js +65 -0
- package/dist/trajectory.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/python/repl_driver.py +744 -0
- package/sandbox/macos.sb +89 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persistent Python REPL driver for @draht/rlm.
|
|
3
|
+
|
|
4
|
+
Spawned once per `RlmSession` (`child_process.spawn("python3", [driverPath])`)
|
|
5
|
+
and kept alive for the session's lifetime so variables persist in one
|
|
6
|
+
`exec()` globals dict across steps.
|
|
7
|
+
|
|
8
|
+
Wire protocol: newline-delimited JSON on stdin/stdout. See the Architecture
|
|
9
|
+
section (2-3) of .planning/phases/26-rlm-core-primitives/26-01-PLAN.md for
|
|
10
|
+
the authoritative spec. Summary:
|
|
11
|
+
|
|
12
|
+
Node -> driver:
|
|
13
|
+
{"type": "exec", "code": "<python source>"}
|
|
14
|
+
{"type": "llm_query_response", "id": "<uuid>", "text": "<result>"}
|
|
15
|
+
{"type": "configure", "stdoutTruncateChars": <int>}
|
|
16
|
+
{"type": "self_test"}
|
|
17
|
+
|
|
18
|
+
Driver -> Node:
|
|
19
|
+
{"type": "llm_query_request", "id": "<uuid>", "prompt": "<prompt>"}
|
|
20
|
+
{"type": "exec_result", "stdout": "<captured stdout>",
|
|
21
|
+
"error": "<traceback or null>",
|
|
22
|
+
"final": null | {"kind": "value", "value": "<str>"}
|
|
23
|
+
| {"kind": "var", "name": "<var name>", "value": "<repr'd value>"}}
|
|
24
|
+
{"type": "self_test_result", "networkBlocked": <bool>, "networkError": <str|null>,
|
|
25
|
+
"fileReadBlocked": <bool>, "fileReadError": <str|null>}
|
|
26
|
+
|
|
27
|
+
`configure` is new in Phase 28 (see Architecture section 5 of
|
|
28
|
+
.planning/phases/28-repl-sandbox-safety/28-01-PLAN.md): `RlmSession` sends
|
|
29
|
+
exactly one right after spawn, before any `exec`, to set the incremental
|
|
30
|
+
stdout cap. It produces no response (fire-and-forget) -- writes to stdin
|
|
31
|
+
are delivered strictly in order, so it's always applied before the first
|
|
32
|
+
real `exec` that follows it.
|
|
33
|
+
|
|
34
|
+
`self_test` is also new in Phase 28 (Architecture section 1's "startup
|
|
35
|
+
self-test"): `RlmSession` sends exactly one, after `configure` and before
|
|
36
|
+
seeding `context` or accepting any root-LLM-authored `exec`, and refuses to
|
|
37
|
+
run at all unless the response's `networkBlocked`/`fileReadBlocked` are both
|
|
38
|
+
`true`. See `_run_self_test`'s docstring for why this deliberately bypasses
|
|
39
|
+
this file's own Python-level guardrails rather than running through the
|
|
40
|
+
normal guarded `exec()` path.
|
|
41
|
+
|
|
42
|
+
Stdlib only: sys, json, io, contextlib, traceback, uuid, ast, builtins, and
|
|
43
|
+
(Linux only, best-effort) resource.
|
|
44
|
+
|
|
45
|
+
Security model (see .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md,
|
|
46
|
+
the plan's "IMPORTANT" section and Architecture sections 1-2): the REAL
|
|
47
|
+
security boundary for this driver is the OS-level process sandbox it is
|
|
48
|
+
spawned inside of (`packages/rlm/src/sandbox.ts` -- `sandbox-exec` on macOS,
|
|
49
|
+
`unshare`/`bwrap` on Linux), established by the caller *before* this script
|
|
50
|
+
ever runs. Everything below this point in the file -- the restricted
|
|
51
|
+
builtins dict, the restricted `__import__`, and the AST pre-screen -- is
|
|
52
|
+
Python-level defense-in-depth for a confused/prompt-injected root LLM (clean
|
|
53
|
+
`ImportError`/guardrail errors instead of a raw crash, keeps it on the
|
|
54
|
+
rails), NOT a claim that arbitrary adversarial Python is safely confined by
|
|
55
|
+
this layer alone. It provably is not: any function/generator object reachable
|
|
56
|
+
through the live Python object graph (e.g.
|
|
57
|
+
`(_ for _ in ()).gi_frame.f_builtins['__import__']`) carries its own
|
|
58
|
+
`__globals__`/`f_builtins` pointing at the process's *real* unrestricted
|
|
59
|
+
builtins, independent of what this module binds as `__builtins__` in the
|
|
60
|
+
exec globals dict. Swapping `__builtins__` here narrows the *names visible
|
|
61
|
+
by direct lookup* in step code; it does not narrow what's reachable via
|
|
62
|
+
attribute traversal. That's exactly why the OS sandbox -- not this file --
|
|
63
|
+
is what actually stops a deliberately adversarial payload.
|
|
64
|
+
|
|
65
|
+
Resource limits (see the same plan's Architecture sections 3-5, Phase 28
|
|
66
|
+
task 3): the REAL memory/time enforcement mechanisms -- RSS polling and a
|
|
67
|
+
wall-clock per-step timeout -- live Node-side in `../src/session.ts`, which
|
|
68
|
+
hard-kills this whole process when either is exceeded. Nothing in this file
|
|
69
|
+
can protect itself from that kill, nor should it try to; there is no
|
|
70
|
+
in-process rollback for a hard kill, only for a recoverable Python exception
|
|
71
|
+
(see `_rollback_step` below). What *does* live here, as the corresponding
|
|
72
|
+
Python-side half of Phase 28 task 3:
|
|
73
|
+
- `_TruncatingStdout`: caps captured stdout incrementally, per `write()`
|
|
74
|
+
call, instead of the old design of buffering everything in a plain
|
|
75
|
+
`io.StringIO()` and truncating Node-side only after the full string
|
|
76
|
+
already arrived (which a step doing many `print()` calls in a loop could
|
|
77
|
+
use to OOM this process before Node ever got a chance to truncate).
|
|
78
|
+
- `_rollback_step`: diff-based (added/changed top-level names), not a
|
|
79
|
+
deepcopy of the whole globals dict -- see its own docstring for why.
|
|
80
|
+
- `_apply_linux_rlimit_as_backstop`: Linux-only, best-effort `RLIMIT_AS`
|
|
81
|
+
backstop. Explicitly NOT the primary memory mechanism (Node's RSS
|
|
82
|
+
polling is) and NOT used on macOS at all -- Darwin's `RLIMIT_AS`
|
|
83
|
+
enforcement is unreliable and a tight limit can itself break interpreter
|
|
84
|
+
startup on arm64.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
import ast
|
|
88
|
+
import builtins
|
|
89
|
+
import contextlib
|
|
90
|
+
import json
|
|
91
|
+
import sys
|
|
92
|
+
import traceback
|
|
93
|
+
import uuid
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
import resource
|
|
97
|
+
except ImportError: # not available on Windows; this driver only ever runs
|
|
98
|
+
# under the macOS/Linux sandbox wrappers in ../src/sandbox.ts, but stay
|
|
99
|
+
# import-safe regardless.
|
|
100
|
+
resource = None
|
|
101
|
+
|
|
102
|
+
# Captured before any `exec()` call can redirect `sys.stdout` (see run_exec's
|
|
103
|
+
# `contextlib.redirect_stdout` below). Driver protocol messages -- including
|
|
104
|
+
# an `llm_query_request` written *during* a redirected exec() -- must always
|
|
105
|
+
# reach the real pipe Node is reading, never the StringIO buffer capturing
|
|
106
|
+
# the executed code's own print() output.
|
|
107
|
+
_REAL_STDOUT = sys.stdout
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _RlmFinal(Exception):
|
|
111
|
+
"""Internal control-flow exception raised by FINAL/FINAL_VAR.
|
|
112
|
+
|
|
113
|
+
Carries the terminal payload as `.payload`. The exec wrapper below
|
|
114
|
+
catches this exception class specifically -- never a bare
|
|
115
|
+
`except Exception` -- so a real `FINAL(...)` call can never be confused
|
|
116
|
+
with the text `FINAL(...)` appearing inside a string, a comment, or
|
|
117
|
+
stdout the executed code happens to print. This is the "brittleness
|
|
118
|
+
safeguard" the plan calls out: FINAL/FINAL_VAR are real function calls
|
|
119
|
+
caught as a real exception, not a stdout text-pattern match.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, payload):
|
|
123
|
+
super().__init__("RLM final result")
|
|
124
|
+
self.payload = payload
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _write_message(message):
|
|
128
|
+
_REAL_STDOUT.write(json.dumps(message) + "\n")
|
|
129
|
+
_REAL_STDOUT.flush()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_message():
|
|
133
|
+
"""Read and parse one newline-delimited JSON message from stdin.
|
|
134
|
+
|
|
135
|
+
Returns None on EOF (stdin closed).
|
|
136
|
+
"""
|
|
137
|
+
line = sys.stdin.readline()
|
|
138
|
+
if line == "":
|
|
139
|
+
return None
|
|
140
|
+
line = line.strip()
|
|
141
|
+
if not line:
|
|
142
|
+
return _read_message()
|
|
143
|
+
return json.loads(line)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def FINAL(answer):
|
|
147
|
+
raise _RlmFinal({"kind": "value", "value": str(answer)})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def make_final_var(globals_dict):
|
|
151
|
+
def FINAL_VAR(name):
|
|
152
|
+
if name not in globals_dict:
|
|
153
|
+
raise NameError(f"FINAL_VAR: name {name!r} is not defined")
|
|
154
|
+
raise _RlmFinal(
|
|
155
|
+
{"kind": "var", "name": name, "value": repr(globals_dict[name])}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return FINAL_VAR
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_self_test():
|
|
162
|
+
"""Runtime proof that the OS-level sandbox is actually enforcing for
|
|
163
|
+
*this* process (Phase 28 Architecture section 1's "startup self-test"),
|
|
164
|
+
handled for the `{"type": "self_test"}` wire message.
|
|
165
|
+
|
|
166
|
+
Deliberately does NOT go through `run_exec`/`_screen_code`/the restricted
|
|
167
|
+
builtins dict -- this is fixed, driver-authored code (not root-LLM
|
|
168
|
+
step code), so it uses `import socket` / `open(...)` directly with the
|
|
169
|
+
real, unrestricted builtins already available to this module. That's
|
|
170
|
+
intentional, not an oversight: a self-test routed through the guarded
|
|
171
|
+
`exec()` path would be a false positive -- e.g. `import socket` would be
|
|
172
|
+
rejected by `_restricted_import`'s allowlist regardless of whether the OS
|
|
173
|
+
sandbox is even present, so "the guarded exec() raised" would look
|
|
174
|
+
identical whether the sandbox is enforcing or completely disabled. The
|
|
175
|
+
whole point of this probe is to observe the OS boundary itself, so it
|
|
176
|
+
must run on a route the Python-level guardrails never touch.
|
|
177
|
+
|
|
178
|
+
Returns a dict with both checks' outcomes; never raises -- a failure to
|
|
179
|
+
run either check *is* a finding (sandbox not enforcing), not a driver
|
|
180
|
+
crash.
|
|
181
|
+
"""
|
|
182
|
+
network_blocked = False
|
|
183
|
+
network_error = None
|
|
184
|
+
try:
|
|
185
|
+
import socket
|
|
186
|
+
|
|
187
|
+
probe_socket = socket.create_connection(("1.1.1.1", 80), timeout=2)
|
|
188
|
+
probe_socket.close()
|
|
189
|
+
except Exception as exc: # noqa: BLE001 - any failure means "blocked", which is the desired outcome
|
|
190
|
+
network_blocked = True
|
|
191
|
+
network_error = repr(exc)
|
|
192
|
+
|
|
193
|
+
file_read_blocked = False
|
|
194
|
+
file_read_error = None
|
|
195
|
+
try:
|
|
196
|
+
with open("/etc/passwd") as handle:
|
|
197
|
+
handle.read()
|
|
198
|
+
except Exception as exc: # noqa: BLE001 - ditto
|
|
199
|
+
file_read_blocked = True
|
|
200
|
+
file_read_error = repr(exc)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"type": "self_test_result",
|
|
204
|
+
"networkBlocked": network_blocked,
|
|
205
|
+
"networkError": network_error,
|
|
206
|
+
"fileReadBlocked": file_read_blocked,
|
|
207
|
+
"fileReadError": file_read_error,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def make_llm_query():
|
|
212
|
+
"""Build the injected `llm_query(prompt)` RPC client.
|
|
213
|
+
|
|
214
|
+
Writes an `llm_query_request` to stdout, then blocks reading stdin for
|
|
215
|
+
the matching `llm_query_response` (matched by `id`) before returning its
|
|
216
|
+
`text`. This is the RPC-over-pipes mechanism that lets Python code call
|
|
217
|
+
back into TypeScript (and therefore into a real or mocked LLM)
|
|
218
|
+
mid-execution.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def llm_query(prompt):
|
|
222
|
+
request_id = str(uuid.uuid4())
|
|
223
|
+
_write_message(
|
|
224
|
+
{"type": "llm_query_request", "id": request_id, "prompt": prompt}
|
|
225
|
+
)
|
|
226
|
+
while True:
|
|
227
|
+
message = _read_message()
|
|
228
|
+
if message is None:
|
|
229
|
+
raise RuntimeError(
|
|
230
|
+
"llm_query: stdin closed while waiting for a response"
|
|
231
|
+
)
|
|
232
|
+
if (
|
|
233
|
+
message.get("type") == "llm_query_response"
|
|
234
|
+
and message.get("id") == request_id
|
|
235
|
+
):
|
|
236
|
+
return message.get("text", "")
|
|
237
|
+
# Anything else while blocked waiting for this specific response
|
|
238
|
+
# is unexpected under the protocol; ignore rather than crash.
|
|
239
|
+
|
|
240
|
+
return llm_query
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Resource limits (Phase 28 Architecture sections 3 and 5) -- the Python-side
|
|
245
|
+
# half only. The REAL wall-clock/memory enforcement is Node-side (RSS
|
|
246
|
+
# polling + step timeout in ../src/session.ts, which hard-kills this whole
|
|
247
|
+
# process); nothing here can substitute for that, and none of it runs after
|
|
248
|
+
# a hard kill (there's no in-process code left to run at that point).
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
# Default matches RlmSession's DEFAULT_STDOUT_TRUNCATE_CHARS (session.ts) --
|
|
252
|
+
# only relevant if a driver is ever spawned/exercised without a `configure`
|
|
253
|
+
# message first (e.g. the other Phase 26/28 test harnesses that talk to this
|
|
254
|
+
# driver directly), so normal short-output test cases aren't affected.
|
|
255
|
+
_DEFAULT_STDOUT_TRUNCATE_CHARS = 2000
|
|
256
|
+
_stdout_truncate_chars = _DEFAULT_STDOUT_TRUNCATE_CHARS
|
|
257
|
+
|
|
258
|
+
# Best-effort Linux-only RLIMIT_AS backstop (Phase 28 Architecture section 3)
|
|
259
|
+
# -- deliberately generous and NOT tied to any per-session RSS ceiling
|
|
260
|
+
# configured Node-side: it only needs to catch truly runaway allocation
|
|
261
|
+
# before the kernel OOM-killer would, as a second line of defense behind
|
|
262
|
+
# Node's RSS polling, which remains the real/primary mechanism everywhere
|
|
263
|
+
# (and the *only* one on macOS -- see the module docstring for why this is
|
|
264
|
+
# skipped there entirely).
|
|
265
|
+
_LINUX_RLIMIT_AS_BYTES = 1536 * 1024 * 1024
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _apply_linux_rlimit_as_backstop():
|
|
269
|
+
"""Sets a generous `RLIMIT_AS` on Linux only. A no-op everywhere else.
|
|
270
|
+
|
|
271
|
+
Best-effort: any failure to read/set the limit is swallowed rather than
|
|
272
|
+
crashing driver startup over a backstop that was never the primary
|
|
273
|
+
mechanism to begin with.
|
|
274
|
+
"""
|
|
275
|
+
if resource is None or not sys.platform.startswith("linux"):
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
_soft, hard = resource.getrlimit(resource.RLIMIT_AS)
|
|
279
|
+
new_limit = (
|
|
280
|
+
_LINUX_RLIMIT_AS_BYTES
|
|
281
|
+
if hard == resource.RLIM_INFINITY
|
|
282
|
+
else min(_LINUX_RLIMIT_AS_BYTES, hard)
|
|
283
|
+
)
|
|
284
|
+
resource.setrlimit(resource.RLIMIT_AS, (new_limit, hard))
|
|
285
|
+
except (ValueError, OSError):
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _TruncatingStdout:
|
|
290
|
+
"""A `contextlib.redirect_stdout` target that caps itself incrementally.
|
|
291
|
+
|
|
292
|
+
Replaces a plain `io.StringIO()` (Phase 26's original design): that
|
|
293
|
+
approach captured the *entire* stdout in memory and relied on Node to
|
|
294
|
+
truncate it after the full string already arrived over the wire --
|
|
295
|
+
meaning a step that does many `print()` calls in a loop could balloon
|
|
296
|
+
this process's memory (and the size of the JSON message written back to
|
|
297
|
+
Node) well before Node ever got a chance to cut it down. This class stops
|
|
298
|
+
accumulating once `limit` characters have been captured; every
|
|
299
|
+
subsequent `write()` is O(1) (just an integer increment), never appended
|
|
300
|
+
to any growing buffer, so the *captured* memory stays bounded regardless
|
|
301
|
+
of how many more times the step calls `print()`.
|
|
302
|
+
|
|
303
|
+
Only implements what `contextlib.redirect_stdout` + `print()` actually
|
|
304
|
+
use (`write`, `flush`) plus `getvalue()` to retrieve the final string --
|
|
305
|
+
not a general `io` implementation.
|
|
306
|
+
|
|
307
|
+
Note this does NOT bound the memory of any single huge value passed to
|
|
308
|
+
`print()` in one call (e.g. `print("x" * 10**9)` builds that 1GB string
|
|
309
|
+
before `write()` ever sees it) -- that's exactly what Node's RSS polling
|
|
310
|
+
(the real memory backstop, see session.ts) exists to catch instead.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(self, limit):
|
|
314
|
+
self._limit = max(0, limit)
|
|
315
|
+
self._chunks = []
|
|
316
|
+
self._length = 0
|
|
317
|
+
self._truncated_chars = 0
|
|
318
|
+
|
|
319
|
+
def write(self, s):
|
|
320
|
+
if not s:
|
|
321
|
+
return 0
|
|
322
|
+
remaining = self._limit - self._length
|
|
323
|
+
if remaining <= 0:
|
|
324
|
+
self._truncated_chars += len(s)
|
|
325
|
+
return len(s)
|
|
326
|
+
if len(s) <= remaining:
|
|
327
|
+
self._chunks.append(s)
|
|
328
|
+
self._length += len(s)
|
|
329
|
+
return len(s)
|
|
330
|
+
keep = s[:remaining]
|
|
331
|
+
self._chunks.append(keep)
|
|
332
|
+
self._length += len(keep)
|
|
333
|
+
self._truncated_chars += len(s) - len(keep)
|
|
334
|
+
return len(s)
|
|
335
|
+
|
|
336
|
+
def flush(self):
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
def getvalue(self):
|
|
340
|
+
value = "".join(self._chunks)
|
|
341
|
+
if self._truncated_chars > 0:
|
|
342
|
+
value += f"\n[truncated {self._truncated_chars} chars]"
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _rollback_step(globals_dict, pre_keys, pre_values):
|
|
347
|
+
"""Diff-based rollback after a recoverable in-process exception.
|
|
348
|
+
|
|
349
|
+
Corrected design per Phase 28 Architecture section 4 -- the plan's
|
|
350
|
+
"IMPORTANT" section explicitly rejects a deepcopy-of-`globals()`
|
|
351
|
+
approach (doesn't scale to a `context` value that can be arbitrarily
|
|
352
|
+
large, copied on every single step regardless of whether it's ever
|
|
353
|
+
touched). Instead: `pre_keys`/`pre_values` are a *shallow* snapshot taken
|
|
354
|
+
before `exec()` (see `run_exec`) -- `dict(globals_dict)` copies key/value
|
|
355
|
+
*references* only, which is O(number of globals), never O(size of any
|
|
356
|
+
individual value) -- and this function does two things with them:
|
|
357
|
+
- Removes every key present now that wasn't present before the step
|
|
358
|
+
(new names the step created).
|
|
359
|
+
- Restores the prior value (by reference, not by copy) for every key
|
|
360
|
+
that existed before and is either missing now (`del`eted by the
|
|
361
|
+
step) or rebound to a different object now (`is not` its prior
|
|
362
|
+
value).
|
|
363
|
+
|
|
364
|
+
Deliberately shallow: an in-place mutation of an existing object (e.g.
|
|
365
|
+
`some_list.append(x)`, with `some_list` never rebound) is NOT rolled
|
|
366
|
+
back -- only name rebindings are. That's the explicit trade-off the plan
|
|
367
|
+
makes in exchange for not deep-copying the whole namespace every step.
|
|
368
|
+
|
|
369
|
+
Never called after a hard kill (timeout/OOM/sandbox failure) -- those
|
|
370
|
+
terminate this whole process from the outside (Node-side); there is no
|
|
371
|
+
Python left running to roll anything back, and no attempt is made to.
|
|
372
|
+
Only reachable from `run_exec`'s `except Exception` branch, i.e. a
|
|
373
|
+
genuinely recoverable in-process exception.
|
|
374
|
+
"""
|
|
375
|
+
post_keys = set(globals_dict.keys())
|
|
376
|
+
for key in post_keys - pre_keys:
|
|
377
|
+
del globals_dict[key]
|
|
378
|
+
for key in pre_keys:
|
|
379
|
+
if key not in globals_dict or globals_dict[key] is not pre_values[key]:
|
|
380
|
+
globals_dict[key] = pre_values[key]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# Python-level guardrails (defense-in-depth -- NOT the security boundary).
|
|
385
|
+
#
|
|
386
|
+
# See the module docstring above: the OS-level sandbox (sandbox.ts) is what
|
|
387
|
+
# actually stops an adversarial payload. This section exists to give a
|
|
388
|
+
# confused/prompt-injected root LLM a clean, actionable error and to keep
|
|
389
|
+
# ordinary generated code from wandering into obviously-unsafe territory --
|
|
390
|
+
# not to make in-process execution of arbitrary Python safe.
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
# Allowlist, not blocklist: only these names are reachable by direct lookup
|
|
394
|
+
# in step code's exec() globals. Deliberately omits `open`, `exec`, `eval`,
|
|
395
|
+
# `compile`, `input`, `breakpoint`, `help`, `getattr`, `setattr`, `vars`,
|
|
396
|
+
# `globals`, `locals`, `type`, and the raw `__import__` (replaced below with
|
|
397
|
+
# a restricted version) per Architecture section 2 of the Phase 28 plan --
|
|
398
|
+
# "prefer omitting" over trying to wrap any of these into a restricted-safe
|
|
399
|
+
# form.
|
|
400
|
+
_ALLOWED_BUILTIN_NAMES = (
|
|
401
|
+
# Types
|
|
402
|
+
"bool",
|
|
403
|
+
"int",
|
|
404
|
+
"float",
|
|
405
|
+
"complex",
|
|
406
|
+
"str",
|
|
407
|
+
"bytes",
|
|
408
|
+
"bytearray",
|
|
409
|
+
"list",
|
|
410
|
+
"tuple",
|
|
411
|
+
"dict",
|
|
412
|
+
"set",
|
|
413
|
+
"frozenset",
|
|
414
|
+
"object",
|
|
415
|
+
"slice",
|
|
416
|
+
# Functions
|
|
417
|
+
"print",
|
|
418
|
+
"len",
|
|
419
|
+
"range",
|
|
420
|
+
"enumerate",
|
|
421
|
+
"zip",
|
|
422
|
+
"map",
|
|
423
|
+
"filter",
|
|
424
|
+
"sorted",
|
|
425
|
+
"reversed",
|
|
426
|
+
"min",
|
|
427
|
+
"max",
|
|
428
|
+
"sum",
|
|
429
|
+
"abs",
|
|
430
|
+
"round",
|
|
431
|
+
"pow",
|
|
432
|
+
"divmod",
|
|
433
|
+
"all",
|
|
434
|
+
"any",
|
|
435
|
+
"iter",
|
|
436
|
+
"next",
|
|
437
|
+
"isinstance",
|
|
438
|
+
"issubclass",
|
|
439
|
+
"repr",
|
|
440
|
+
"format",
|
|
441
|
+
"hash",
|
|
442
|
+
"chr",
|
|
443
|
+
"ord",
|
|
444
|
+
"hex",
|
|
445
|
+
"oct",
|
|
446
|
+
"bin",
|
|
447
|
+
"callable",
|
|
448
|
+
"id",
|
|
449
|
+
# Constants
|
|
450
|
+
"NotImplemented",
|
|
451
|
+
# Exception family -- deliberately excludes BaseException, SystemExit,
|
|
452
|
+
# KeyboardInterrupt, and GeneratorExit, none of which a normal data-
|
|
453
|
+
# processing step legitimately needs by name, and all of which could
|
|
454
|
+
# otherwise be used to unwind past this driver's own exec() wrapper (see
|
|
455
|
+
# run_exec's `except Exception`, not `except BaseException`, below).
|
|
456
|
+
"Exception",
|
|
457
|
+
"ArithmeticError",
|
|
458
|
+
"AssertionError",
|
|
459
|
+
"AttributeError",
|
|
460
|
+
"BufferError",
|
|
461
|
+
"EOFError",
|
|
462
|
+
"FileExistsError",
|
|
463
|
+
"FileNotFoundError",
|
|
464
|
+
"FloatingPointError",
|
|
465
|
+
"ImportError",
|
|
466
|
+
"IndentationError",
|
|
467
|
+
"IndexError",
|
|
468
|
+
"IOError",
|
|
469
|
+
"KeyError",
|
|
470
|
+
"LookupError",
|
|
471
|
+
"MemoryError",
|
|
472
|
+
"ModuleNotFoundError",
|
|
473
|
+
"NameError",
|
|
474
|
+
"NotImplementedError",
|
|
475
|
+
"OSError",
|
|
476
|
+
"OverflowError",
|
|
477
|
+
"PermissionError",
|
|
478
|
+
"RecursionError",
|
|
479
|
+
"ReferenceError",
|
|
480
|
+
"RuntimeError",
|
|
481
|
+
"StopAsyncIteration",
|
|
482
|
+
"StopIteration",
|
|
483
|
+
"SyntaxError",
|
|
484
|
+
"SystemError",
|
|
485
|
+
"TabError",
|
|
486
|
+
"TypeError",
|
|
487
|
+
"UnboundLocalError",
|
|
488
|
+
"UnicodeDecodeError",
|
|
489
|
+
"UnicodeEncodeError",
|
|
490
|
+
"UnicodeError",
|
|
491
|
+
"UnicodeTranslateError",
|
|
492
|
+
"ValueError",
|
|
493
|
+
"ZeroDivisionError",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Import allowlist per R28-SBX.1 / Architecture section 2: only these
|
|
497
|
+
# top-level stdlib modules may be imported from step code. Anything else
|
|
498
|
+
# (most notably `os`, `sys`, `subprocess`, `socket`, `urllib`, `shutil`,
|
|
499
|
+
# `pathlib`, `ctypes`) raises a clear ImportError instead of a raw crash.
|
|
500
|
+
_ALLOWED_IMPORT_MODULES = frozenset(
|
|
501
|
+
{"re", "json", "math", "itertools", "collections", "statistics"}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
|
|
506
|
+
"""Replacement for `__import__` bound into the restricted builtins dict.
|
|
507
|
+
|
|
508
|
+
Checks only the top-level package name (`"os.path"` -> `"os"`) against
|
|
509
|
+
`_ALLOWED_IMPORT_MODULES` so `import collections` / `from collections
|
|
510
|
+
import OrderedDict` / `import itertools as it` all resolve normally for
|
|
511
|
+
allowed modules, while anything outside the allowlist -- including
|
|
512
|
+
submodules of a disallowed package -- is rejected before the real
|
|
513
|
+
`__import__` (captured via the top-level `import builtins` above, which
|
|
514
|
+
this function itself is not going through) ever runs.
|
|
515
|
+
"""
|
|
516
|
+
top_level = name.split(".", 1)[0]
|
|
517
|
+
if top_level not in _ALLOWED_IMPORT_MODULES:
|
|
518
|
+
raise ImportError(
|
|
519
|
+
f"import of {name!r} is not allowed in the RLM sandbox guardrail "
|
|
520
|
+
f"layer; allowed modules: {sorted(_ALLOWED_IMPORT_MODULES)}"
|
|
521
|
+
)
|
|
522
|
+
return builtins.__import__(name, globals, locals, fromlist, level)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _build_restricted_builtins():
|
|
526
|
+
"""Constructs the exec globals' `__builtins__` dict from the allowlist.
|
|
527
|
+
|
|
528
|
+
CPython honors a plain dict (not just a module) bound to `__builtins__`
|
|
529
|
+
in an exec() globals dict as the builtins namespace directly, so any
|
|
530
|
+
name not explicitly copied in here (or `_restricted_import`, bound to
|
|
531
|
+
`__import__` below) is simply unresolvable by direct name lookup from
|
|
532
|
+
step code -- it raises `NameError`, not a permission error, which is the
|
|
533
|
+
correct/expected shape for an allowlist.
|
|
534
|
+
"""
|
|
535
|
+
restricted = {name: getattr(builtins, name) for name in _ALLOWED_BUILTIN_NAMES}
|
|
536
|
+
restricted["__import__"] = _restricted_import
|
|
537
|
+
return restricted
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# Attribute names that are the well-known object-graph escape routes flagged
|
|
541
|
+
# by the security consult behind this plan (see the plan's "IMPORTANT"
|
|
542
|
+
# section) -- reaching any of these from a step is rejected outright,
|
|
543
|
+
# regardless of how the attribute is reached (dotted access on any
|
|
544
|
+
# expression, not just a literal name).
|
|
545
|
+
_DUNDER_ESCAPE_ATTRS = frozenset(
|
|
546
|
+
{
|
|
547
|
+
"__class__",
|
|
548
|
+
"__subclasses__",
|
|
549
|
+
"__bases__",
|
|
550
|
+
"__base__",
|
|
551
|
+
"__globals__",
|
|
552
|
+
"__builtins__",
|
|
553
|
+
"__import__",
|
|
554
|
+
"__mro__",
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Non-dunder frame-related attribute names that are equally dangerous --
|
|
559
|
+
# required because the litmus case
|
|
560
|
+
# `(_ for _ in ()).gi_frame.f_builtins['__import__']('os')` reaches the real
|
|
561
|
+
# builtins without touching a single dunder, so a dunder-only check would
|
|
562
|
+
# miss it entirely.
|
|
563
|
+
_FRAME_ESCAPE_ATTRS = frozenset(
|
|
564
|
+
{"gi_frame", "f_builtins", "f_globals", "f_back", "cr_frame", "ag_frame"}
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Names that are dangerous purely by being called/referenced directly, even
|
|
568
|
+
# though they're already omitted from the restricted builtins dict above --
|
|
569
|
+
# the AST screen catches these as a *pre*-exec, clear-error rejection (see
|
|
570
|
+
# _GuardrailViolation) instead of letting a step partially execute up to the
|
|
571
|
+
# point where it would otherwise hit a NameError.
|
|
572
|
+
_BANNED_NAMES = frozenset(
|
|
573
|
+
{
|
|
574
|
+
"eval",
|
|
575
|
+
"exec",
|
|
576
|
+
"compile",
|
|
577
|
+
"getattr",
|
|
578
|
+
"setattr",
|
|
579
|
+
"vars",
|
|
580
|
+
"globals",
|
|
581
|
+
"locals",
|
|
582
|
+
"__import__",
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class _GuardrailViolation(Exception):
|
|
588
|
+
"""Raised by `_screen_code` when the AST pre-screen rejects a step.
|
|
589
|
+
|
|
590
|
+
Caught in `run_exec` and turned into a plain `exec_result.error` string
|
|
591
|
+
-- a driver-level guardrail rejection, not a raw Python exception -- and
|
|
592
|
+
raised *before* any exec() call, so a rejected step has zero side
|
|
593
|
+
effects (nothing it contains ever runs, not even the part before the
|
|
594
|
+
offending construct).
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _screen_code(code):
|
|
599
|
+
"""AST pre-screen: rejects known escape-technique shapes before exec().
|
|
600
|
+
|
|
601
|
+
Parses `code` and walks every node, rejecting (via `_GuardrailViolation`)
|
|
602
|
+
if it finds:
|
|
603
|
+
- Attribute access naming a dunder escape route (`_DUNDER_ESCAPE_ATTRS`).
|
|
604
|
+
- Attribute access naming a non-dunder frame escape route
|
|
605
|
+
(`_FRAME_ESCAPE_ATTRS`).
|
|
606
|
+
- A direct `Name` reference to a banned name (`_BANNED_NAMES`) --
|
|
607
|
+
covers both bare references and calls, since `foo(...)` parses as a
|
|
608
|
+
`Call` node whose `func` is itself a `Name` node that `ast.walk`
|
|
609
|
+
visits independently.
|
|
610
|
+
|
|
611
|
+
A `SyntaxError` from `ast.parse` itself is deliberately NOT a guardrail
|
|
612
|
+
rejection -- that's not an escape attempt, just invalid code, and letting
|
|
613
|
+
it fall through to the normal `exec()` call preserves Phase 26's existing
|
|
614
|
+
`exec_result.error` reporting for plain syntax errors.
|
|
615
|
+
"""
|
|
616
|
+
try:
|
|
617
|
+
tree = ast.parse(code)
|
|
618
|
+
except SyntaxError:
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
for node in ast.walk(tree):
|
|
622
|
+
if isinstance(node, ast.Attribute):
|
|
623
|
+
if node.attr in _DUNDER_ESCAPE_ATTRS:
|
|
624
|
+
raise _GuardrailViolation(
|
|
625
|
+
f"blocked by RLM sandbox guardrail: attribute access to "
|
|
626
|
+
f"{node.attr!r} is not allowed (object-graph escape risk)"
|
|
627
|
+
)
|
|
628
|
+
if node.attr in _FRAME_ESCAPE_ATTRS:
|
|
629
|
+
raise _GuardrailViolation(
|
|
630
|
+
f"blocked by RLM sandbox guardrail: attribute access to "
|
|
631
|
+
f"{node.attr!r} is not allowed (frame escape risk)"
|
|
632
|
+
)
|
|
633
|
+
elif isinstance(node, ast.Name):
|
|
634
|
+
if node.id in _BANNED_NAMES:
|
|
635
|
+
raise _GuardrailViolation(
|
|
636
|
+
f"blocked by RLM sandbox guardrail: use of {node.id!r} "
|
|
637
|
+
"is not allowed"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def build_globals():
|
|
642
|
+
globals_dict = {
|
|
643
|
+
"__name__": "__rlm_repl__",
|
|
644
|
+
"__builtins__": _build_restricted_builtins(),
|
|
645
|
+
}
|
|
646
|
+
globals_dict["FINAL"] = FINAL
|
|
647
|
+
globals_dict["FINAL_VAR"] = make_final_var(globals_dict)
|
|
648
|
+
globals_dict["llm_query"] = make_llm_query()
|
|
649
|
+
return globals_dict
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def run_exec(code, globals_dict):
|
|
653
|
+
"""Execute one step's code against the persistent globals dict.
|
|
654
|
+
|
|
655
|
+
Runs the AST pre-screen (`_screen_code`) first -- a rejection there
|
|
656
|
+
short-circuits before `exec()` is ever called, so a rejected step has no
|
|
657
|
+
side effects at all (see `_GuardrailViolation`'s docstring).
|
|
658
|
+
|
|
659
|
+
Takes a shallow pre-exec snapshot (`pre_keys`/`pre_values`, see
|
|
660
|
+
`_rollback_step`'s docstring for why this is O(number of globals), not
|
|
661
|
+
O(size of `context` or anything else)) and, on a recoverable in-process
|
|
662
|
+
exception, rolls the step back via `_rollback_step` (Phase 28
|
|
663
|
+
Architecture section 4) before returning. No rollback is attempted for
|
|
664
|
+
`FINAL`/`FINAL_VAR` (that's the normal termination path, not an error) --
|
|
665
|
+
only for the `except Exception` branch below.
|
|
666
|
+
|
|
667
|
+
Returns (stdout, error, final):
|
|
668
|
+
- stdout: captured print()/stdout output as a str, already capped +
|
|
669
|
+
marked by `_TruncatingStdout` (Phase 28 Architecture section 5) at
|
|
670
|
+
`_stdout_truncate_chars`.
|
|
671
|
+
- error: traceback string (or a guardrail-rejection message), or None
|
|
672
|
+
if no (non-FINAL) exception/rejection occurred.
|
|
673
|
+
- final: the FINAL/FINAL_VAR payload dict, or None if neither was
|
|
674
|
+
called.
|
|
675
|
+
"""
|
|
676
|
+
try:
|
|
677
|
+
_screen_code(code)
|
|
678
|
+
except _GuardrailViolation as guardrail_exc:
|
|
679
|
+
return "", str(guardrail_exc), None
|
|
680
|
+
|
|
681
|
+
pre_keys = set(globals_dict.keys())
|
|
682
|
+
pre_values = dict(globals_dict) # shallow -- see _rollback_step's docstring
|
|
683
|
+
|
|
684
|
+
stdout_buffer = _TruncatingStdout(_stdout_truncate_chars)
|
|
685
|
+
error = None
|
|
686
|
+
final = None
|
|
687
|
+
try:
|
|
688
|
+
with contextlib.redirect_stdout(stdout_buffer):
|
|
689
|
+
exec(code, globals_dict)
|
|
690
|
+
except _RlmFinal as final_exc:
|
|
691
|
+
final = final_exc.payload
|
|
692
|
+
except Exception:
|
|
693
|
+
error = traceback.format_exc()
|
|
694
|
+
_rollback_step(globals_dict, pre_keys, pre_values)
|
|
695
|
+
return stdout_buffer.getvalue(), error, final
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main():
|
|
699
|
+
global _stdout_truncate_chars
|
|
700
|
+
_apply_linux_rlimit_as_backstop()
|
|
701
|
+
globals_dict = build_globals()
|
|
702
|
+
while True:
|
|
703
|
+
message = _read_message()
|
|
704
|
+
if message is None:
|
|
705
|
+
break
|
|
706
|
+
msg_type = message.get("type")
|
|
707
|
+
if msg_type == "configure":
|
|
708
|
+
value = message.get("stdoutTruncateChars")
|
|
709
|
+
if isinstance(value, int) and value > 0:
|
|
710
|
+
_stdout_truncate_chars = value
|
|
711
|
+
continue
|
|
712
|
+
if msg_type == "self_test":
|
|
713
|
+
_write_message(_run_self_test())
|
|
714
|
+
continue
|
|
715
|
+
if msg_type == "exec":
|
|
716
|
+
code = message.get("code", "")
|
|
717
|
+
stdout, error, final = run_exec(code, globals_dict)
|
|
718
|
+
_write_message(
|
|
719
|
+
{
|
|
720
|
+
"type": "exec_result",
|
|
721
|
+
"stdout": stdout,
|
|
722
|
+
"error": error,
|
|
723
|
+
"final": final,
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
elif msg_type == "llm_query_response":
|
|
727
|
+
# Only meaningful while llm_query() is blocked reading stdin
|
|
728
|
+
# directly (see make_llm_query above). If one reaches the main
|
|
729
|
+
# loop there is no pending request awaiting it; ignore it rather
|
|
730
|
+
# than crashing the driver.
|
|
731
|
+
continue
|
|
732
|
+
else:
|
|
733
|
+
_write_message(
|
|
734
|
+
{
|
|
735
|
+
"type": "exec_result",
|
|
736
|
+
"stdout": "",
|
|
737
|
+
"error": f"unknown message type: {msg_type!r}",
|
|
738
|
+
"final": None,
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
if __name__ == "__main__":
|
|
744
|
+
main()
|