@hicaru/pi-rlm 0.3.0 → 0.3.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 CHANGED
@@ -166,6 +166,24 @@ inherited by every child spawned afterwards.
166
166
  > each holds a full Python process and its own copy of the inherited context. Error and
167
167
  > wall-clock caps (above) still bound a runaway tree.
168
168
 
169
+ ## Subagents and environment
170
+
171
+ RLM never confiscates native file tools (`read` / `grep` / bash readers) unless `repl` is in
172
+ the **active** tool set — the paper's trade is all-or-nothing. Process-boundary subagents that
173
+ spawn pi with a `--tools` allowlist without `repl` therefore keep ordinary file access.
174
+
175
+ Optional env conventions (for packages that want an explicit full bypass):
176
+
177
+ | Env | Meaning |
178
+ |---|---|
179
+ | `PI_SUBAGENT_CHILD=1` | Full RLM bypass in this process (no tools / hooks / flags). |
180
+ | `PI_RLM_FORCE_IN_SUBAGENT=1` | Experimental: opt a child back into RLM. **Consumed on activate** (not inherited after). Refused when `PI_RLM_DEPTH >= maxDepth`. |
181
+ | `PI_RLM_DEPTH` | Cross-process depth counter (default `0`). Bumped when force-in activates. |
182
+
183
+ In-process recursion (`rlm_query`) still uses `maxDepth` from `/rlm-config` and is unrelated to
184
+ these env vars. Set `RLM_TRACE_FILE` to a path for JSONL traces of bypass / force / block-skip
185
+ decisions.
186
+
169
187
  ## Security
170
188
 
171
189
  - **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -21,7 +21,21 @@ import { formatContextListing } from "./context/listing.ts";
21
21
  import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
22
22
  import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
23
23
  import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
24
+ import {
25
+ isSubagentChildBypass,
26
+ commitSubagentForceActivation,
27
+ shouldEnforceNativeReaderBlock,
28
+ processRlmDepth,
29
+ } from "./mode/subagent.ts";
24
30
  import { errorMessage } from "./util/errors.ts";
31
+ import { trace, traceEnabled } from "./util/trace.ts";
32
+
33
+ export {
34
+ isSubagentChildBypass,
35
+ commitSubagentForceActivation,
36
+ shouldEnforceNativeReaderBlock,
37
+ processRlmDepth,
38
+ } from "./mode/subagent.ts";
25
39
 
26
40
  const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
27
41
  /** How often to keep the parent sandbox's request watchdog alive during detached work. */
@@ -29,6 +43,23 @@ const WATCHDOG_HEARTBEAT_MS = 30_000;
29
43
  const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
30
44
 
31
45
  export default function rlmExtension(pi: ExtensionAPI): void {
46
+ // Subagent children run a native tool flow; RLM's contract is the opposite
47
+ // (block read/grep, route through repl). Env fast path: full bypass when
48
+ // PI_SUBAGENT_CHILD=1 (unless force-in under the depth cap). See mode/subagent.ts.
49
+ if (isSubagentChildBypass()) {
50
+ if (traceEnabled) {
51
+ trace("subagent.bypass", {
52
+ reason: process.env.PI_RLM_FORCE_IN_SUBAGENT === "1" ? "force_depth_cap" : "child",
53
+ depth: processRlmDepth(),
54
+ });
55
+ }
56
+ return;
57
+ }
58
+ commitSubagentForceActivation();
59
+ if (traceEnabled && process.env.PI_SUBAGENT_CHILD === "1") {
60
+ trace("subagent.force", { depth: processRlmDepth() });
61
+ }
62
+
32
63
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
33
64
  const config = mergeConfig({});
34
65
  const controller = new RlmController(config);
@@ -203,9 +234,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
203
234
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
204
235
  });
205
236
 
206
- // ── System prompt: native RLM mode addendum (only when enabled) ──
237
+ /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
238
+ const nativeTradeHolds = (): boolean =>
239
+ shouldEnforceNativeReaderBlock({
240
+ enabled: controller.enabled,
241
+ activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
242
+ });
243
+
244
+ // ── System prompt: native RLM mode addendum (only when the trade holds) ──
207
245
  pi.on("before_agent_start", async (event) => {
208
- if (!controller.enabled) return;
246
+ if (!nativeTradeHolds()) return;
209
247
  return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
210
248
  });
211
249
 
@@ -218,7 +256,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
218
256
  !(message.role === "custom" && message.customType === "rlm-intro")
219
257
  && !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
220
258
  );
221
- if (!controller.enabled) return { messages: filtered };
259
+ if (!nativeTradeHolds()) return { messages: filtered };
222
260
 
223
261
  type PiMessage = (typeof filtered)[number];
224
262
 
@@ -256,8 +294,15 @@ export default function rlmExtension(pi: ExtensionAPI): void {
256
294
  // `edit`/`write` stay unblocked so the agent modifies files through Pi's native
257
295
  // tool flow (visible to all plugins, +/- diff preview). File reading/searching
258
296
  // belongs in the REPL, and bash output is capped as a backstop.
297
+ // Fail-open when repl is not active (e.g. --tools allowlist without repl): never
298
+ // confiscate readers without a working substitute (RLM paper §2 trade).
259
299
  pi.on("tool_call", async (event) => {
260
- if (!controller.enabled) return;
300
+ if (!nativeTradeHolds()) {
301
+ if (traceEnabled && controller.enabled) {
302
+ trace("native.block_skip", { toolName: event.toolName, reason: "no_active_repl" });
303
+ }
304
+ return;
305
+ }
261
306
  if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
262
307
  return {
263
308
  block: true,
@@ -271,7 +316,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
271
316
  });
272
317
 
273
318
  pi.on("tool_result", async (event) => {
274
- if (!controller.enabled || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
319
+ if (!nativeTradeHolds() || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
275
320
  let changed = false;
276
321
  const content = event.content.map((c) => {
277
322
  if (c.type !== "text") return c;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subagent / process-boundary isolation for RLM.
3
+ *
4
+ * Two layers:
5
+ * 1. Env fast path — packages that set PI_SUBAGENT_CHILD=1 fully bypass RLM.
6
+ * 2. Capability gate — never confiscate native readers unless `repl` is in the
7
+ * active tool set (paper trade: scaffold only if the REPL substitute exists).
8
+ *
9
+ * In-process rlm_query depth is handled by subcall-handlers.childRun; this module
10
+ * only covers OS-process children (pi subagents), which restart at depth 0.
11
+ */
12
+ import { DEFAULT_CONFIG } from "../config/defaults.ts";
13
+
14
+ export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
+ export const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
16
+ export const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
17
+
18
+ /** Cross-process depth from env. Missing / invalid → 0. */
19
+ export function processRlmDepth(): number {
20
+ const raw = process.env[RLM_DEPTH_ENV];
21
+ if (raw === undefined || raw === "") return 0;
22
+ const n = Number.parseInt(raw, 10);
23
+ if (!Number.isFinite(n) || n < 0) return 0;
24
+ return n;
25
+ }
26
+
27
+ /**
28
+ * True when this process should not activate RLM at all (no tools / hooks / flags).
29
+ *
30
+ * - Parent (no PI_SUBAGENT_CHILD=1) → false.
31
+ * - Child without force → true.
32
+ * - Child with force but depth >= maxDepth → true (refuse force; paper §7 cost bound).
33
+ * - Child with force and depth < maxDepth → false (experimental opt-in).
34
+ */
35
+ export function isSubagentChildBypass(maxDepth: number = DEFAULT_CONFIG.maxDepth): boolean {
36
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return false;
37
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return true;
38
+ return processRlmDepth() >= maxDepth;
39
+ }
40
+
41
+ /**
42
+ * Call only when RLM will activate. Scrubs force so grandchildren that inherit env
43
+ * do not re-open unbounded force; bumps PI_RLM_DEPTH for any re-set force path.
44
+ * No-op when not a forced child under the depth cap.
45
+ */
46
+ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.maxDepth): void {
47
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return;
48
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return;
49
+ if (processRlmDepth() >= maxDepth) return;
50
+ const next = processRlmDepth() + 1;
51
+ delete process.env[RLM_FORCE_IN_SUBAGENT_ENV];
52
+ process.env[RLM_DEPTH_ENV] = String(next);
53
+ }
54
+
55
+ /**
56
+ * RLM's native-mode trade: confiscate read/grep (and bash readers) only when the
57
+ * substitute is actually callable. Fail-open when the active tool list is unknown
58
+ * or does not include `repl` (official pi subagent uses --tools without repl).
59
+ */
60
+ export function shouldEnforceNativeReaderBlock(opts: {
61
+ readonly enabled: boolean;
62
+ readonly activeToolNames: readonly string[] | undefined;
63
+ }): boolean {
64
+ if (!opts.enabled) return false;
65
+ const names = opts.activeToolNames;
66
+ if (names === undefined) return false;
67
+ return names.includes("repl");
68
+ }
@@ -19,6 +19,13 @@ import sys
19
19
  from contextlib import contextmanager
20
20
  from typing import Any
21
21
 
22
+ from hostio import pin_stdio_utf8
23
+
24
+ # Pin UTF-8 before anything reads or writes a frame. On Windows these three default to the
25
+ # locale encoding (cp1252), while the JSONL protocol Node speaks is UTF-8 both ways — see
26
+ # issue #7. reconfigure() mutates in place, so the REAL_* captures below are the same objects.
27
+ pin_stdio_utf8()
28
+
22
29
  # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
23
30
  # All protocol writes must go to the real stdout even while user code's prints are captured.
24
31
  REAL_STDOUT = sys.stdout
@@ -0,0 +1,57 @@
1
+ """Host↔worker transport, pinned to UTF-8.
2
+
3
+ Every byte the host hands this worker is UTF-8: the context temp files come from Node's
4
+ FileHandle.write(string) (default utf8, see sandbox/context-file.ts) and the JSONL frames come
5
+ off a pipe Node writes the same way. Python does NOT match that — text I/O defaults to the
6
+ locale encoding, which is cp1252 on a Western Windows install. That mismatch is issue #7: a
7
+ packed repo holding any non-ASCII byte raised UnicodeDecodeError before the REPL ever ran.
8
+
9
+ Encoding is therefore never left to the locale here. Interpreter-wide UTF-8 mode (-X utf8=1,
10
+ set by the host in sandbox.ts) covers model-written open(); these two functions cover the
11
+ scaffold's own I/O, and they win outright — PYTHONIOENCODING overrides -X utf8=1, and
12
+ reconfigure() overrides PYTHONIOENCODING.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import sys
20
+ from typing import Any
21
+
22
+ ENCODING = "utf-8"
23
+
24
+
25
+ def read_host_payload(path: str, is_json: bool) -> Any:
26
+ """Read a payload the host serialized to a temp file.
27
+
28
+ Strict on decode errors by design. The host always writes UTF-8, so a failure here is a
29
+ transport bug, not bad input — and errors="replace" would hand the model silently
30
+ mojibake'd source code instead of surfacing the problem.
31
+
32
+ The explicit encoding= is intentionally redundant with -X utf8=1 under a normal spawn:
33
+ UTF-8 mode already makes the default encoding UTF-8. It still matters so a standalone
34
+ import of this module (or a worker started without -X utf8=1) cannot silently reintroduce
35
+ issue #7. phase-encoding.ts check 6 (AST / EncodingWarning) is the only automated guard
36
+ that encoding= is present — do not drop either side of that net.
37
+ """
38
+ with io.open(path, "r", encoding=ENCODING) as f:
39
+ return json.load(f) if is_json else f.read()
40
+
41
+
42
+ def pin_stdio_utf8() -> None:
43
+ """Force the three real streams to UTF-8, whatever the locale or PYTHONIOENCODING says.
44
+
45
+ The write side uses surrogatepass, not strict: model code can synthesize a lone surrogate
46
+ (a bad decode, a truncated pair), and json.dumps(ensure_ascii=False) would then raise
47
+ inside _send — killing the worker on a request it had already completed. The read side
48
+ uses replace so a corrupt frame comes back as a "bad json" error response instead of an
49
+ exception that escapes main()'s loop and takes the worker down (fail-soft I/O, AGENTS.md).
50
+ """
51
+ for stream, errors in (
52
+ (sys.stdin, "replace"),
53
+ (sys.stdout, "surrogatepass"),
54
+ (sys.stderr, "surrogatepass"),
55
+ ):
56
+ if isinstance(stream, io.TextIOWrapper): # not a TextIOWrapper under some test harnesses
57
+ stream.reconfigure(encoding=ENCODING, errors=errors)
@@ -44,6 +44,7 @@ from guards import (
44
44
  REAL_STDERR as _REAL_STDERR,
45
45
  REAL_STDIN as _REAL_STDIN,
46
46
  )
47
+ from hostio import read_host_payload
47
48
  from retrieval import (
48
49
  _Bm25Index,
49
50
  _chunk_text,
@@ -591,8 +592,7 @@ class Worker:
591
592
  if not isinstance(path, str):
592
593
  return "Error: malformed add_context reply (no path)"
593
594
  try:
594
- with io.open(path, "r") as f:
595
- payload = json.load(f) if r.get("json") else f.read()
595
+ payload = read_host_payload(path, bool(r.get("json")))
596
596
  finally:
597
597
  try:
598
598
  os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
@@ -706,8 +706,7 @@ class Worker:
706
706
  `index` is accepted for protocol compatibility but ignored — there is only
707
707
  one context slot. Sources are merged on the host (or via add_context).
708
708
  """
709
- with open(path, "r") as f:
710
- payload = json.load(f) if is_json else f.read()
709
+ payload = read_host_payload(path, bool(is_json))
711
710
  self._context_payload = payload
712
711
  self.ns["context"] = payload
713
712
  # Drop legacy multi-slot names if present.
@@ -111,6 +111,11 @@ export class PythonSandbox {
111
111
  this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
112
112
  const python = opts.python ?? "python3";
113
113
  const workerArgs = [
114
+ // -X utf8=1: the scaffold states its own encoding explicitly (py/hostio.py), but MODEL
115
+ // code gets a real open() — guards.py exposes it deliberately — and on Windows that
116
+ // would default to cp1252 (issue #7). UTF-8 mode covers the whole interpreter; the
117
+ // scaffold's explicit encoding= still wins where PYTHONIOENCODING would override this.
118
+ "-X", "utf8=1",
114
119
  "-u", WORKER_PATH,
115
120
  "--depth", String(opts.depth ?? 1),
116
121
  "--timeout", String(opts.execTimeoutS ?? 600),
@@ -124,7 +129,9 @@ export class PythonSandbox {
124
129
  this.proc = spawn(
125
130
  python,
126
131
  workerArgs,
127
- { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
132
+ // windowsHide: without it each sandbox flashes a console window on Windows (pi sets
133
+ // this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
134
+ { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
128
135
  ) as ChildProcessWithoutNullStreams;
129
136
 
130
137
  this.proc.stdout.setEncoding("utf8");