@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.
@@ -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()