@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,273 @@
1
+ /**
2
+ * OS-level sandbox spawn wrapper for the RLM Python REPL driver.
3
+ *
4
+ * This is the REAL security boundary described in
5
+ * .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md ("IMPORTANT" section
6
+ * and Architecture section 1) -- NOT `repl_driver.py`'s Python-level
7
+ * builtins/import allowlist (that's defense-in-depth for a
8
+ * confused/prompt-injected LLM, not something that stops a deliberately
9
+ * adversarial payload; the Python object graph makes in-process
10
+ * confinement of arbitrary Python fundamentally unachievable -- see the
11
+ * plan for the `__subclasses__`/`gi_frame.f_builtins` escapes that defeat
12
+ * any purely in-process approach).
13
+ *
14
+ * **macOS**: wraps the interpreter with `sandbox-exec -f macos.sb ...`. The
15
+ * SBPL profile (`../sandbox/macos.sb`) denies network and process-exec
16
+ * entirely (which alone defeats `os.system`/`subprocess` -- they fork+exec
17
+ * a shell -- independent of any Python-level import block) and scopes file
18
+ * access to exactly the Python installation needed to boot plus the
19
+ * session's workdir. Machine-specific paths (where python3 actually lives)
20
+ * are discovered here at spawn time via a real subprocess call, never
21
+ * hardcoded/guessed -- see `resolvePythonInstallation`.
22
+ *
23
+ * **Linux**: wraps with `unshare --user --map-root-user --net --mount --`
24
+ * (empty net namespace = network unreachable at the kernel; mount
25
+ * namespace for FS confinement), falling back to `bwrap` with equivalent
26
+ * flags if `unshare` isn't available. Availability is detected at spawn
27
+ * time, never assumed.
28
+ *
29
+ * **Fail closed everywhere**: if the sandboxing mechanism for the current
30
+ * platform can't be established (wrapper binary missing, profile missing,
31
+ * Python installation can't be discovered, unsupported platform), every
32
+ * function here throws `SandboxUnavailableError` synchronously rather than
33
+ * returning a command that would run the driver unwrapped. There is no
34
+ * fallback path to an unsandboxed spawn anywhere in this module.
35
+ */
36
+ import { execFileSync, spawn } from "node:child_process";
37
+ import { existsSync, realpathSync } from "node:fs";
38
+ import { dirname, join, delimiter as pathDelimiter } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ const __dirname = dirname(fileURLToPath(import.meta.url));
41
+ const MACOS_PROFILE_PATH = join(__dirname, "..", "sandbox", "macos.sb");
42
+ const DEFAULT_SANDBOX_EXEC_BIN = "/usr/bin/sandbox-exec";
43
+ /**
44
+ * Thrown whenever the OS-level sandbox can't be established for the current
45
+ * platform/environment. Callers (see `session.ts`) must let this propagate
46
+ * and refuse to run rather than catching it and falling back to an
47
+ * unwrapped spawn -- that fallback is exactly the failure mode Architecture
48
+ * section 1's "fail closed" requirement exists to prevent.
49
+ */
50
+ export class SandboxUnavailableError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = "SandboxUnavailableError";
54
+ }
55
+ }
56
+ const pythonInstallationCache = new Map();
57
+ /**
58
+ * Asks the given `python3` binary (via a real subprocess call -- there is no
59
+ * static/config-file way to learn a Python install's true executable path
60
+ * and stdlib root; every install lays these out differently) where it and
61
+ * its stdlib actually live, then checks for the macOS framework-build
62
+ * re-exec companion described above. Memoized per `pythonBin` string since
63
+ * the answer can't change during this process's lifetime and this runs on
64
+ * every sandboxed spawn.
65
+ */
66
+ function resolvePythonInstallation(pythonBin) {
67
+ const cached = pythonInstallationCache.get(pythonBin);
68
+ if (cached)
69
+ return cached;
70
+ let stdout;
71
+ try {
72
+ stdout = execFileSync(pythonBin, ["-c", "import sys; print(sys.executable); print(sys.prefix)"], {
73
+ encoding: "utf8",
74
+ });
75
+ }
76
+ catch (err) {
77
+ throw new SandboxUnavailableError(`sandbox_violation: could not run ${JSON.stringify(pythonBin)} to discover its installation -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
78
+ }
79
+ const lines = stdout.trim().split("\n");
80
+ const executableLine = lines[0];
81
+ const prefixLine = lines[1];
82
+ if (!executableLine || !prefixLine) {
83
+ throw new SandboxUnavailableError(`sandbox_violation: unexpected output discovering the Python installation for ${JSON.stringify(pythonBin)}: ${JSON.stringify(stdout)}`);
84
+ }
85
+ let realBin;
86
+ let prefix;
87
+ try {
88
+ realBin = realpathSync(executableLine.trim());
89
+ prefix = realpathSync(prefixLine.trim());
90
+ }
91
+ catch (err) {
92
+ throw new SandboxUnavailableError(`sandbox_violation: could not resolve real filesystem paths for the Python installation -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
93
+ }
94
+ const reexecCandidate = join(prefix, "Resources", "Python.app", "Contents", "MacOS", "Python");
95
+ const reexecBin = existsSync(reexecCandidate) ? realpathSync(reexecCandidate) : realBin;
96
+ const installation = { realBin, reexecBin, prefix };
97
+ pythonInstallationCache.set(pythonBin, installation);
98
+ return installation;
99
+ }
100
+ /** Resolves a bare command name against `PATH`, returning `null` if not found. Used for the Linux wrappers. */
101
+ function resolveOnPath(name) {
102
+ const pathEnv = process.env.PATH ?? "";
103
+ for (const dir of pathEnv.split(pathDelimiter)) {
104
+ if (!dir)
105
+ continue;
106
+ const candidate = join(dir, name);
107
+ if (existsSync(candidate))
108
+ return candidate;
109
+ }
110
+ return null;
111
+ }
112
+ function buildMacosCommand(opts) {
113
+ const sandboxExecBin = opts.sandboxExecBin ?? DEFAULT_SANDBOX_EXEC_BIN;
114
+ if (!existsSync(sandboxExecBin)) {
115
+ throw new SandboxUnavailableError(`sandbox_violation: macOS sandbox-exec binary not found at ${JSON.stringify(sandboxExecBin)} -- refusing to run the REPL driver unsandboxed.`);
116
+ }
117
+ if (!existsSync(MACOS_PROFILE_PATH)) {
118
+ throw new SandboxUnavailableError(`sandbox_violation: sandbox profile missing at ${JSON.stringify(MACOS_PROFILE_PATH)} -- refusing to run the REPL driver unsandboxed.`);
119
+ }
120
+ const { realBin, reexecBin, prefix } = resolvePythonInstallation(opts.pythonBin ?? "python3");
121
+ let workdir;
122
+ let driverPath;
123
+ try {
124
+ workdir = realpathSync(opts.workdir);
125
+ driverPath = realpathSync(opts.driverPath);
126
+ }
127
+ catch (err) {
128
+ throw new SandboxUnavailableError(`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
129
+ }
130
+ return {
131
+ command: sandboxExecBin,
132
+ args: [
133
+ "-D",
134
+ `PYTHON_REAL_BIN=${realBin}`,
135
+ "-D",
136
+ `PYTHON_REEXEC_BIN=${reexecBin}`,
137
+ "-D",
138
+ `PYTHON_PREFIX=${prefix}`,
139
+ "-D",
140
+ `DRIVER_PATH=${driverPath}`,
141
+ "-D",
142
+ `WORKDIR=${workdir}`,
143
+ "-f",
144
+ MACOS_PROFILE_PATH,
145
+ realBin,
146
+ driverPath,
147
+ ],
148
+ };
149
+ }
150
+ function buildLinuxCommand(opts) {
151
+ const pythonBin = opts.pythonBin ?? "python3";
152
+ let workdir;
153
+ let driverPath;
154
+ try {
155
+ workdir = realpathSync(opts.workdir);
156
+ driverPath = realpathSync(opts.driverPath);
157
+ }
158
+ catch (err) {
159
+ throw new SandboxUnavailableError(`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
160
+ }
161
+ const unshareBin = opts.unshareBin ?? resolveOnPath("unshare");
162
+ if (unshareBin && existsSync(unshareBin)) {
163
+ // Empty user+net namespace: no interfaces besides loopback exist, so
164
+ // network is unreachable at the kernel regardless of anything Python
165
+ // itself tries. Mount namespace confines the filesystem view; the
166
+ // workdir bind-mount (rw) plus the rest of the root (ro, implicit --
167
+ // `unshare --mount` alone doesn't remount anything) is a coarser
168
+ // boundary than the macOS profile's explicit allowlist, which is why
169
+ // `--map-root-user` + a private mount namespace is paired with the
170
+ // driver only ever being told about `workdir` for file I/O at the
171
+ // Python-guardrail layer (Phase 28 task 2) -- the kernel-level net
172
+ // denial is what task 1 owns and is unconditional here.
173
+ return {
174
+ command: unshareBin,
175
+ args: ["--user", "--map-root-user", "--net", "--mount", "--", pythonBin, driverPath],
176
+ };
177
+ }
178
+ const bwrapBin = opts.bwrapBin ?? resolveOnPath("bwrap");
179
+ if (bwrapBin && existsSync(bwrapBin)) {
180
+ const binds = ["/usr", "/lib", "/bin", "/sbin"]
181
+ .filter((path) => existsSync(path))
182
+ .flatMap((path) => ["--ro-bind", path, path]);
183
+ if (existsSync("/lib64"))
184
+ binds.push("--ro-bind", "/lib64", "/lib64");
185
+ return {
186
+ command: bwrapBin,
187
+ args: [
188
+ "--unshare-user",
189
+ "--unshare-net",
190
+ "--unshare-pid",
191
+ "--die-with-parent",
192
+ ...binds,
193
+ "--bind",
194
+ workdir,
195
+ workdir,
196
+ "--proc",
197
+ "/proc",
198
+ "--dev",
199
+ "/dev",
200
+ "--chdir",
201
+ workdir,
202
+ pythonBin,
203
+ driverPath,
204
+ ],
205
+ };
206
+ }
207
+ throw new SandboxUnavailableError("sandbox_violation: neither `unshare` nor `bwrap` is available on this Linux host -- refusing to run the REPL driver unsandboxed.");
208
+ }
209
+ /**
210
+ * Computes the sandboxed command/args for the current platform without
211
+ * spawning anything. Exposed separately from `spawnSandboxed` so tests can
212
+ * assert on the resolved invocation and so fail-closed behavior (throwing
213
+ * `SandboxUnavailableError`) is observable synchronously, before any
214
+ * process exists.
215
+ */
216
+ export function resolveSandboxedCommand(opts) {
217
+ if (process.platform === "darwin")
218
+ return buildMacosCommand(opts);
219
+ if (process.platform === "linux")
220
+ return buildLinuxCommand(opts);
221
+ throw new SandboxUnavailableError(`sandbox_violation: no OS-level sandbox implementation for platform ${JSON.stringify(process.platform)} -- refusing to run the REPL driver unsandboxed.`);
222
+ }
223
+ /**
224
+ * Environment variables the sandboxed interpreter is allowed to see, by
225
+ * allowlist rather than denylist. The OS sandbox itself denies network
226
+ * access outright, so this isn't the primary defense against exfiltrating
227
+ * anything read from here -- but the same object-graph escapes that defeat
228
+ * in-process Python restriction (see this module's doc comment) can still
229
+ * reach `os.environ` and write it into the session workdir even with
230
+ * network denied, so the child process should never see more of the parent
231
+ * Node process's environment (API keys, tokens, credentials, ...) than it
232
+ * actually needs to boot a working interpreter.
233
+ */
234
+ const SAFE_ENV_ALLOWLIST = [
235
+ "PATH",
236
+ "HOME",
237
+ "LANG",
238
+ "LANGUAGE",
239
+ "LC_ALL",
240
+ "LC_CTYPE",
241
+ "TMPDIR",
242
+ "TZ",
243
+ "PYTHONHASHSEED",
244
+ "PYTHONIOENCODING",
245
+ "PYTHONUTF8",
246
+ ];
247
+ /**
248
+ * Builds a scrubbed environment for the sandboxed child process from
249
+ * `SAFE_ENV_ALLOWLIST` -- deliberately NOT `{ ...process.env }` (see
250
+ * `SAFE_ENV_ALLOWLIST`'s doc comment for why the full parent environment,
251
+ * secrets included, must not be handed to a process running LLM-authored
252
+ * code).
253
+ */
254
+ function buildScrubbedEnv() {
255
+ const scrubbed = {};
256
+ for (const key of SAFE_ENV_ALLOWLIST) {
257
+ const value = process.env[key];
258
+ if (value !== undefined)
259
+ scrubbed[key] = value;
260
+ }
261
+ return scrubbed;
262
+ }
263
+ /**
264
+ * Spawns `repl_driver.py` wrapped in the platform's OS-level sandbox.
265
+ * Throws `SandboxUnavailableError` synchronously (before any process is
266
+ * created) if the sandbox can't be established -- there is no unwrapped
267
+ * fallback path.
268
+ */
269
+ export function spawnSandboxed(opts) {
270
+ const { command, args } = resolveSandboxedCommand(opts);
271
+ return spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: buildScrubbedEnv() });
272
+ }
273
+ //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * OS-level sandbox spawn wrapper for the RLM Python REPL driver.\n *\n * This is the REAL security boundary described in\n * .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md (\"IMPORTANT\" section\n * and Architecture section 1) -- NOT `repl_driver.py`'s Python-level\n * builtins/import allowlist (that's defense-in-depth for a\n * confused/prompt-injected LLM, not something that stops a deliberately\n * adversarial payload; the Python object graph makes in-process\n * confinement of arbitrary Python fundamentally unachievable -- see the\n * plan for the `__subclasses__`/`gi_frame.f_builtins` escapes that defeat\n * any purely in-process approach).\n *\n * **macOS**: wraps the interpreter with `sandbox-exec -f macos.sb ...`. The\n * SBPL profile (`../sandbox/macos.sb`) denies network and process-exec\n * entirely (which alone defeats `os.system`/`subprocess` -- they fork+exec\n * a shell -- independent of any Python-level import block) and scopes file\n * access to exactly the Python installation needed to boot plus the\n * session's workdir. Machine-specific paths (where python3 actually lives)\n * are discovered here at spawn time via a real subprocess call, never\n * hardcoded/guessed -- see `resolvePythonInstallation`.\n *\n * **Linux**: wraps with `unshare --user --map-root-user --net --mount --`\n * (empty net namespace = network unreachable at the kernel; mount\n * namespace for FS confinement), falling back to `bwrap` with equivalent\n * flags if `unshare` isn't available. Availability is detected at spawn\n * time, never assumed.\n *\n * **Fail closed everywhere**: if the sandboxing mechanism for the current\n * platform can't be established (wrapper binary missing, profile missing,\n * Python installation can't be discovered, unsupported platform), every\n * function here throws `SandboxUnavailableError` synchronously rather than\n * returning a command that would run the driver unwrapped. There is no\n * fallback path to an unsandboxed spawn anywhere in this module.\n */\n\nimport { type ChildProcess, execFileSync, spawn } from \"node:child_process\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { dirname, join, delimiter as pathDelimiter } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst MACOS_PROFILE_PATH = join(__dirname, \"..\", \"sandbox\", \"macos.sb\");\nconst DEFAULT_SANDBOX_EXEC_BIN = \"/usr/bin/sandbox-exec\";\n\n/**\n * Thrown whenever the OS-level sandbox can't be established for the current\n * platform/environment. Callers (see `session.ts`) must let this propagate\n * and refuse to run rather than catching it and falling back to an\n * unwrapped spawn -- that fallback is exactly the failure mode Architecture\n * section 1's \"fail closed\" requirement exists to prevent.\n */\nexport class SandboxUnavailableError extends Error {\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"SandboxUnavailableError\";\n\t}\n}\n\nexport interface SandboxSpawnOptions {\n\t/** Absolute path to `repl_driver.py`. */\n\tdriverPath: string;\n\t/**\n\t * Absolute path to the session's scratch workdir. The only place the\n\t * sandboxed process may read *and* write freely (besides the read-only\n\t * Python installation itself).\n\t */\n\tworkdir: string;\n\t/**\n\t * Which `python3` to sandbox. Defaults to whatever `python3` resolves to\n\t * on `PATH`. Overridable mainly so tests can point at a specific\n\t * interpreter without mutating `PATH`.\n\t */\n\tpythonBin?: string;\n\t/**\n\t * Override for the macOS `sandbox-exec` binary path. Mainly a test\n\t * injection point for proving fail-closed behavior (point it at a\n\t * nonexistent path and assert setup refuses rather than falling back to\n\t * an unwrapped spawn) -- see Architecture section 1's fail-closed\n\t * requirement and this plan's task 1 test 7.\n\t */\n\tsandboxExecBin?: string;\n\t/** Override for the Linux `unshare` binary path. Mainly for tests. */\n\tunshareBin?: string;\n\t/** Override for the Linux `bwrap` binary path (used if `unshare` is unavailable). Mainly for tests. */\n\tbwrapBin?: string;\n}\n\n/** The concrete command + args a `child_process.spawn` call should use. */\nexport interface SandboxedCommand {\n\tcommand: string;\n\targs: string[];\n}\n\ninterface PythonInstallation {\n\t/** `realpath()`'d absolute path to the interpreter binary that will actually run. */\n\trealBin: string;\n\t/**\n\t * Some macOS \"framework\" Python builds (the python.org installer,\n\t * Homebrew's `--with-framework`) internally `posix_spawn()` themselves\n\t * into a second binary at startup\n\t * (`Resources/Python.app/Contents/MacOS/Python`) before any user code\n\t * runs, to get a normal `.app` bundle identity (Tk/Cocoa access, Dock\n\t * icon, etc.). Confirmed empirically while scoping this profile: without\n\t * allowing this second exec, dyld aborts before `main()` ever runs. Equal\n\t * to `realBin` when no such companion binary exists.\n\t */\n\treexecBin: string;\n\t/** `realpath()`'d `sys.prefix` -- the install root the interpreter needs read access to in order to boot. */\n\tprefix: string;\n}\n\nconst pythonInstallationCache = new Map<string, PythonInstallation>();\n\n/**\n * Asks the given `python3` binary (via a real subprocess call -- there is no\n * static/config-file way to learn a Python install's true executable path\n * and stdlib root; every install lays these out differently) where it and\n * its stdlib actually live, then checks for the macOS framework-build\n * re-exec companion described above. Memoized per `pythonBin` string since\n * the answer can't change during this process's lifetime and this runs on\n * every sandboxed spawn.\n */\nfunction resolvePythonInstallation(pythonBin: string): PythonInstallation {\n\tconst cached = pythonInstallationCache.get(pythonBin);\n\tif (cached) return cached;\n\n\tlet stdout: string;\n\ttry {\n\t\tstdout = execFileSync(pythonBin, [\"-c\", \"import sys; print(sys.executable); print(sys.prefix)\"], {\n\t\t\tencoding: \"utf8\",\n\t\t});\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not run ${JSON.stringify(pythonBin)} to discover its installation -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst lines = stdout.trim().split(\"\\n\");\n\tconst executableLine = lines[0];\n\tconst prefixLine = lines[1];\n\tif (!executableLine || !prefixLine) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: unexpected output discovering the Python installation for ${JSON.stringify(pythonBin)}: ${JSON.stringify(stdout)}`,\n\t\t);\n\t}\n\n\tlet realBin: string;\n\tlet prefix: string;\n\ttry {\n\t\trealBin = realpathSync(executableLine.trim());\n\t\tprefix = realpathSync(prefixLine.trim());\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve real filesystem paths for the Python installation -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst reexecCandidate = join(prefix, \"Resources\", \"Python.app\", \"Contents\", \"MacOS\", \"Python\");\n\tconst reexecBin = existsSync(reexecCandidate) ? realpathSync(reexecCandidate) : realBin;\n\n\tconst installation: PythonInstallation = { realBin, reexecBin, prefix };\n\tpythonInstallationCache.set(pythonBin, installation);\n\treturn installation;\n}\n\n/** Resolves a bare command name against `PATH`, returning `null` if not found. Used for the Linux wrappers. */\nfunction resolveOnPath(name: string): string | null {\n\tconst pathEnv = process.env.PATH ?? \"\";\n\tfor (const dir of pathEnv.split(pathDelimiter)) {\n\t\tif (!dir) continue;\n\t\tconst candidate = join(dir, name);\n\t\tif (existsSync(candidate)) return candidate;\n\t}\n\treturn null;\n}\n\nfunction buildMacosCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tconst sandboxExecBin = opts.sandboxExecBin ?? DEFAULT_SANDBOX_EXEC_BIN;\n\tif (!existsSync(sandboxExecBin)) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: macOS sandbox-exec binary not found at ${JSON.stringify(sandboxExecBin)} -- refusing to run the REPL driver unsandboxed.`,\n\t\t);\n\t}\n\tif (!existsSync(MACOS_PROFILE_PATH)) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: sandbox profile missing at ${JSON.stringify(MACOS_PROFILE_PATH)} -- refusing to run the REPL driver unsandboxed.`,\n\t\t);\n\t}\n\n\tconst { realBin, reexecBin, prefix } = resolvePythonInstallation(opts.pythonBin ?? \"python3\");\n\n\tlet workdir: string;\n\tlet driverPath: string;\n\ttry {\n\t\tworkdir = realpathSync(opts.workdir);\n\t\tdriverPath = realpathSync(opts.driverPath);\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\treturn {\n\t\tcommand: sandboxExecBin,\n\t\targs: [\n\t\t\t\"-D\",\n\t\t\t`PYTHON_REAL_BIN=${realBin}`,\n\t\t\t\"-D\",\n\t\t\t`PYTHON_REEXEC_BIN=${reexecBin}`,\n\t\t\t\"-D\",\n\t\t\t`PYTHON_PREFIX=${prefix}`,\n\t\t\t\"-D\",\n\t\t\t`DRIVER_PATH=${driverPath}`,\n\t\t\t\"-D\",\n\t\t\t`WORKDIR=${workdir}`,\n\t\t\t\"-f\",\n\t\t\tMACOS_PROFILE_PATH,\n\t\t\trealBin,\n\t\t\tdriverPath,\n\t\t],\n\t};\n}\n\nfunction buildLinuxCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tconst pythonBin = opts.pythonBin ?? \"python3\";\n\n\tlet workdir: string;\n\tlet driverPath: string;\n\ttry {\n\t\tworkdir = realpathSync(opts.workdir);\n\t\tdriverPath = realpathSync(opts.driverPath);\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst unshareBin = opts.unshareBin ?? resolveOnPath(\"unshare\");\n\tif (unshareBin && existsSync(unshareBin)) {\n\t\t// Empty user+net namespace: no interfaces besides loopback exist, so\n\t\t// network is unreachable at the kernel regardless of anything Python\n\t\t// itself tries. Mount namespace confines the filesystem view; the\n\t\t// workdir bind-mount (rw) plus the rest of the root (ro, implicit --\n\t\t// `unshare --mount` alone doesn't remount anything) is a coarser\n\t\t// boundary than the macOS profile's explicit allowlist, which is why\n\t\t// `--map-root-user` + a private mount namespace is paired with the\n\t\t// driver only ever being told about `workdir` for file I/O at the\n\t\t// Python-guardrail layer (Phase 28 task 2) -- the kernel-level net\n\t\t// denial is what task 1 owns and is unconditional here.\n\t\treturn {\n\t\t\tcommand: unshareBin,\n\t\t\targs: [\"--user\", \"--map-root-user\", \"--net\", \"--mount\", \"--\", pythonBin, driverPath],\n\t\t};\n\t}\n\n\tconst bwrapBin = opts.bwrapBin ?? resolveOnPath(\"bwrap\");\n\tif (bwrapBin && existsSync(bwrapBin)) {\n\t\tconst binds = [\"/usr\", \"/lib\", \"/bin\", \"/sbin\"]\n\t\t\t.filter((path) => existsSync(path))\n\t\t\t.flatMap((path) => [\"--ro-bind\", path, path]);\n\t\tif (existsSync(\"/lib64\")) binds.push(\"--ro-bind\", \"/lib64\", \"/lib64\");\n\t\treturn {\n\t\t\tcommand: bwrapBin,\n\t\t\targs: [\n\t\t\t\t\"--unshare-user\",\n\t\t\t\t\"--unshare-net\",\n\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\"--die-with-parent\",\n\t\t\t\t...binds,\n\t\t\t\t\"--bind\",\n\t\t\t\tworkdir,\n\t\t\t\tworkdir,\n\t\t\t\t\"--proc\",\n\t\t\t\t\"/proc\",\n\t\t\t\t\"--dev\",\n\t\t\t\t\"/dev\",\n\t\t\t\t\"--chdir\",\n\t\t\t\tworkdir,\n\t\t\t\tpythonBin,\n\t\t\t\tdriverPath,\n\t\t\t],\n\t\t};\n\t}\n\n\tthrow new SandboxUnavailableError(\n\t\t\"sandbox_violation: neither `unshare` nor `bwrap` is available on this Linux host -- refusing to run the REPL driver unsandboxed.\",\n\t);\n}\n\n/**\n * Computes the sandboxed command/args for the current platform without\n * spawning anything. Exposed separately from `spawnSandboxed` so tests can\n * assert on the resolved invocation and so fail-closed behavior (throwing\n * `SandboxUnavailableError`) is observable synchronously, before any\n * process exists.\n */\nexport function resolveSandboxedCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tif (process.platform === \"darwin\") return buildMacosCommand(opts);\n\tif (process.platform === \"linux\") return buildLinuxCommand(opts);\n\tthrow new SandboxUnavailableError(\n\t\t`sandbox_violation: no OS-level sandbox implementation for platform ${JSON.stringify(process.platform)} -- refusing to run the REPL driver unsandboxed.`,\n\t);\n}\n\n/**\n * Environment variables the sandboxed interpreter is allowed to see, by\n * allowlist rather than denylist. The OS sandbox itself denies network\n * access outright, so this isn't the primary defense against exfiltrating\n * anything read from here -- but the same object-graph escapes that defeat\n * in-process Python restriction (see this module's doc comment) can still\n * reach `os.environ` and write it into the session workdir even with\n * network denied, so the child process should never see more of the parent\n * Node process's environment (API keys, tokens, credentials, ...) than it\n * actually needs to boot a working interpreter.\n */\nconst SAFE_ENV_ALLOWLIST = [\n\t\"PATH\",\n\t\"HOME\",\n\t\"LANG\",\n\t\"LANGUAGE\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TMPDIR\",\n\t\"TZ\",\n\t\"PYTHONHASHSEED\",\n\t\"PYTHONIOENCODING\",\n\t\"PYTHONUTF8\",\n] as const;\n\n/**\n * Builds a scrubbed environment for the sandboxed child process from\n * `SAFE_ENV_ALLOWLIST` -- deliberately NOT `{ ...process.env }` (see\n * `SAFE_ENV_ALLOWLIST`'s doc comment for why the full parent environment,\n * secrets included, must not be handed to a process running LLM-authored\n * code).\n */\nfunction buildScrubbedEnv(): NodeJS.ProcessEnv {\n\tconst scrubbed: NodeJS.ProcessEnv = {};\n\tfor (const key of SAFE_ENV_ALLOWLIST) {\n\t\tconst value = process.env[key];\n\t\tif (value !== undefined) scrubbed[key] = value;\n\t}\n\treturn scrubbed;\n}\n\n/**\n * Spawns `repl_driver.py` wrapped in the platform's OS-level sandbox.\n * Throws `SandboxUnavailableError` synchronously (before any process is\n * created) if the sandbox can't be established -- there is no unwrapped\n * fallback path.\n */\nexport function spawnSandboxed(opts: SandboxSpawnOptions): ChildProcess {\n\tconst { command, args } = resolveSandboxedCommand(opts);\n\treturn spawn(command, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"], env: buildScrubbedEnv() });\n}\n"]}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `RlmSession` — the RLM root loop.
3
+ *
4
+ * root-LLM-produces-code -> REPL-executes -> truncated-stdout ->
5
+ * history-append -> FINAL-check, per
6
+ * .planning/phases/26-rlm-core-primitives/26-01-PLAN.md, Architecture
7
+ * section 4. Talks to `../python/repl_driver.py` over the newline-delimited
8
+ * JSON wire protocol documented in that file's module docstring (and
9
+ * section 2-3 of the plan).
10
+ */
11
+ import type { RlmHistoryEntry, RlmResult, RlmSessionOptions } from "./types.js";
12
+ /**
13
+ * Extracts Python source from a root-LLM response.
14
+ *
15
+ * Per R26-RLM.4, the response is expected to contain a ```repl or ```python
16
+ * fenced block (possibly surrounded by prose explaining the plan). Falls
17
+ * back to the whole (trimmed) response when no fence is present, so mocked
18
+ * `rootLlm` callbacks in tests can return raw code directly.
19
+ */
20
+ export declare function extractPythonCode(response: string): string;
21
+ /**
22
+ * Truncates captured stdout to `limit` characters, appending an explicit
23
+ * marker for how many characters were cut — matching the `[truncated N
24
+ * chars]` format R28-SBX.4 (Phase 28) will enforce as a hard cap. This phase
25
+ * only applies it as a plain formatting rule, not a safety mechanism.
26
+ */
27
+ export declare function truncateStdout(stdout: string, limit: number): string;
28
+ /**
29
+ * Restricted Python-literal parser used to turn a `FINAL_VAR`-produced
30
+ * `repr()` string back into a real JS value.
31
+ *
32
+ * Handles the JSON-safe subset the plan calls out: `None`/`True`/`False`,
33
+ * int/float, single- or double-quoted strings (with the common backslash
34
+ * escapes), lists, tuples (mapped to arrays — JS has no tuple type), and
35
+ * dicts (mapped to plain objects; keys are coerced with `String(...)`).
36
+ * Anything it can't parse (custom object reprs like `<Foo object at
37
+ * 0x...>`, sets, NaN/Infinity, etc.) falls back to the raw repr string
38
+ * rather than throwing, so a result is never lost — see the module-level
39
+ * note in this file's neighboring test for why raw-string fallback beats
40
+ * failing the whole run.
41
+ */
42
+ export declare function pythonReprToValue(repr: string): unknown;
43
+ export declare class RlmSession {
44
+ private readonly opts;
45
+ private readonly child;
46
+ private readonly workdir;
47
+ private readonly stdoutTruncateChars;
48
+ private readonly maxIterations;
49
+ private readonly stepTimeoutMs;
50
+ private readonly maxRssBytes;
51
+ private readonly rssPollIntervalMs;
52
+ private readonly maxSubCalls;
53
+ private readonly maxTotalCostUsd;
54
+ private readonly getAccumulatedCostUsd;
55
+ private readonly history;
56
+ private readonly ready;
57
+ private buffer;
58
+ private messageQueue;
59
+ private waiters;
60
+ private terminated;
61
+ private terminationError;
62
+ private lastFinal;
63
+ private subCallCount;
64
+ private rssPollTimer;
65
+ private rssPollInFlight;
66
+ constructor(opts: RlmSessionOptions);
67
+ private onStdoutChunk;
68
+ private markTerminated;
69
+ /**
70
+ * Hard-kills the driver subprocess and marks the session terminated with
71
+ * `error` in one step. Used by every resource-limit enforcement path (RSS
72
+ * ceiling, wall-clock timeout, budget exhaustion) -- these are Node-side
73
+ * hard kills, not attempts at graceful in-process recovery (Phase 28
74
+ * Architecture sections 3 and 6). `markTerminated`'s first-write-wins
75
+ * guard means whichever of these fires first supplies the message callers
76
+ * actually see, even though the child's own `exit` handler will also fire
77
+ * shortly after and try to (harmlessly, redundantly) mark terminated too.
78
+ */
79
+ private killAndTerminate;
80
+ /**
81
+ * Starts the Node-side RSS poll loop (Phase 28 Architecture section 3) --
82
+ * the real memory enforcement mechanism, not any Python-level
83
+ * `RLIMIT_AS`. Runs for the child process's whole lifetime; `unref()`d so
84
+ * it never by itself keeps the host Node process alive past everything
85
+ * else finishing. A `maxRssBytes <= 0` disables polling entirely.
86
+ */
87
+ private startRssPolling;
88
+ private pollRss;
89
+ /**
90
+ * Pre-dispatch sub-call budget check (Phase 28 Architecture section 6,
91
+ * R28-SBX.5): called *before* an `llm_query_request` is ever forwarded to
92
+ * `opts.llmQuery`, so the (budget+1)th attempted sub-call never actually
93
+ * dispatches -- the session is killed right here instead. Deliberately
94
+ * NOT also checked before dispatching a whole step: a step that never
95
+ * attempts another sub-call can still legitimately finish (e.g. call
96
+ * FINAL immediately) even after the budget is otherwise exhausted, and
97
+ * pre-emptively killing such a step here would be wrong.
98
+ */
99
+ private checkSubCallBudget;
100
+ /**
101
+ * Pre-dispatch cost budget check (Phase 28 Architecture section 6,
102
+ * R28-SBX.5): called before dispatching a step's `rootLlm` turn and
103
+ * before forwarding a sub-call to `opts.llmQuery`. No-ops unless both
104
+ * `maxTotalCostUsd` and `getAccumulatedCostUsd` are supplied -- see
105
+ * `RlmSessionOptions.maxTotalCostUsd`'s doc comment for why an unpaired
106
+ * option is a no-op rather than a guess.
107
+ */
108
+ private checkCostBudget;
109
+ /**
110
+ * Races `promise` against the configured wall-clock step timeout
111
+ * (Phase 28 Architecture section 3, R28-SBX.3) -- the real timeout
112
+ * enforcement mechanism. On expiry, hard-kills the driver subprocess and
113
+ * rejects with `RlmStepTimeoutError`, even if `promise` itself is stuck
114
+ * awaiting something that `markTerminated`'s waiter-rejection can't reach
115
+ * (e.g. a slow/hung `opts.llmQuery` callback, as opposed to a pending
116
+ * `nextMessage()` wait) -- the explicit `reject` here is what actually
117
+ * unblocks the caller in that case, not just a formality.
118
+ */
119
+ private withStepTimeout;
120
+ private nextMessage;
121
+ private send;
122
+ /**
123
+ * Sends one `exec` message and handles any `llm_query_request` round-trips
124
+ * until the matching `exec_result`, all under the wall-clock step timeout
125
+ * (`withStepTimeout`, Phase 28 Architecture section 3).
126
+ */
127
+ private runExec;
128
+ /** The message loop `runExec` races against the step timeout -- split out so `withStepTimeout` can wrap just this. */
129
+ private collectExecResult;
130
+ private seedContext;
131
+ /**
132
+ * Runtime proof the OS-level sandbox is actually enforcing for *this*
133
+ * process (Phase 28 Architecture section 1) -- sends `{"type":
134
+ * "self_test"}` and requires the driver's `self_test_result` response to
135
+ * report both a network connect attempt and an out-of-workdir file read
136
+ * failing. Throws `RlmSandboxViolationError` (fail-closed, refusing to run
137
+ * at all) if:
138
+ * - the driver never responds (crash/hang -- guarded by the same
139
+ * wall-clock timeout as a normal step, via `withStepTimeout`),
140
+ * - the response isn't a well-formed `self_test_result`, or
141
+ * - either check reports the sandbox did NOT block the attempt.
142
+ *
143
+ * Deliberately does not go through `runExec`/the guarded `exec()` wire
144
+ * message: `repl_driver.py`'s `_run_self_test` handles `self_test`
145
+ * on a route that bypasses its own Python-level guardrails (restricted
146
+ * builtins, import allowlist, AST screen) entirely, because a self-test
147
+ * *routed through* those guardrails would be a false positive -- e.g.
148
+ * `import socket` failing on the guardrail import allowlist looks
149
+ * identical to `import socket` failing because the OS sandbox denied the
150
+ * connection, even with the OS sandbox completely disabled. See
151
+ * `_run_self_test`'s docstring in repl_driver.py for the full reasoning.
152
+ */
153
+ private runSelfTest;
154
+ private resolveFinal;
155
+ /** One root-LLM turn: get code, exec it, append history, may resolve a final. */
156
+ step(): Promise<RlmHistoryEntry>;
157
+ /** Loops `step()` until a FINAL/FINAL_VAR, `maxIterations`, or an unrecoverable driver error. */
158
+ run(): Promise<RlmResult>;
159
+ /** Kills the underlying python3 subprocess and removes its scratch workdir. Safe to call more than once. */
160
+ dispose(): void;
161
+ }
162
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AASH,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA4GhF;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CASvD;AAoND,qBAAa,UAAU;IAwBV,OAAO,CAAC,QAAQ,CAAC,IAAI;IAvBjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA6B;IACnE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyB;IACjD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IAEtC,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,gBAAgB,CAAsB;IAC9C,OAAO,CAAC,SAAS,CAAmC;IACpD,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,eAAe,CAAS;IAEhC,YAA6B,IAAI,EAAE,iBAAiB,EAyEnD;IAED,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,cAAc;IAatB;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IAKxB;;;;;;OAMG;IACH,OAAO,CAAC,eAAe;YAST,OAAO;IAoBrB;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAYvB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,IAAI;IAOZ;;;;OAIG;YACW,OAAO;IAKrB,sHAAsH;YACxG,iBAAiB;YA4BjB,WAAW;IAOzB;;;;;;;;;;;;;;;;;;;;;OAqBG;YACW,WAAW;IAyBzB,OAAO,CAAC,YAAY;IAWpB,iFAAiF;IAC3E,IAAI,IAAI,OAAO,CAAC,eAAe,CAAC,CAyBrC;IAED,iGAAiG;IAC3F,GAAG,IAAI,OAAO,CAAC,SAAS,CAAC,CAoD9B;IAED,4GAA4G;IAC5G,OAAO,IAAI,IAAI,CAUd;CACD"}