# Discord Sage: read-only tools (iteration 6) Issue #1509, QUEUE row 21. Follows the pilot brief `2026-09-13_discord-connector-pilot.md` (sections 8 and 11). Jason on 2026-09-13: "Let's move to tools." This is the brief for his ruling before anything is built, because it moves a security boundary: it decides what the Discord Sage can read on this host, and every listed Discord user can make it read there. ## 1. What holds today The engine launches `pi --mode rpc --no-tools --no-extensions --no-context-files --no-skills --offline` with the repository as its working directory (`packages/discord/src/engine-pi.mjs`). Rulings Q1 and Q15 say chat only, no tools, no files. One prompt is exactly one pi turn, and the engine settles on that turn's `turn_end`. Discord text is treated as data, not instructions (Q15). Listed users: Jason everywhere, Carmen in every listed room except #sage-admin (row 20). ## 2. The boundary, stated plainly pi has no sandbox. Its built-in `read`, `ls`, `grep` and `find` run with the permissions of the pi process, which is Jason's user on this host: the seat tokens under `~/.mosaic`, the binding with everyone's Discord ids, and the private evidence directory are all one absolute path away. pi's own security page says isolation must come from the operating system or a container, not from pi. So the built-in tools are out. Two honest options: 1. Run pi for Discord inside the existing Mosaic container the way `scripts/agent.sh` and `scripts/run-task.sh` do, with read-only mounts of the chosen directories. Strongest boundary, but the engine's stdio transport, the session directory, the auth file and the live restart story all change. That is the shape for repository writes later. 2. Keep pi on the host, keep `--no-builtin-tools`, and load one Mosaic extension that registers three read-only tools confined to declared roots. The boundary is then our code, small and tested, with the same fail-closed rules the binding loader already applies to context files (real path under the root, no symlinks, regular files only). This brief recommends 2 for read-only, with 1 recorded as the route for writes. Reason: the read-only need is served by a few hundred lines that the offline suite can prove, and the operational model that just landed (service unit, brake, reload) stays as it is. ## 3. Design ### 3.1 The extension `packages/discord/extension/readonly-tools.mjs`, plain JavaScript, loaded with `--extension`. It reads its configuration from one environment variable the engine sets, `MOSAIC_DISCORD_TOOLS` (JSON), and refuses to load, which refuses the pi start and therefore the connector, when the variable is missing or invalid. Nothing is defaulted. Tools registered, all read-only, all confined: | Tool | Parameters | Returns | |---|---|---| | `list_dir` | `root`, `path` (relative, optional) | names with type and size, capped at 200 entries | | `read_file` | `root`, `path`, `offset` (line, optional), `limit` (lines, default 200, max 400) | the text window with line numbers, plus total line count | | `search` | `root`, `text` (fixed string, no regex), `path` (relative subtree, optional) | up to 50 hits as `path:line: text`, files scanned capped at 2000 | Confinement, applied on every call before any read, in this order: - `root` must name a declared root; the request never carries an absolute path. The real path of the root is resolved once at load. - The joined path is resolved with `realpath`; it must start with the root's real path plus a separator, or equal it for `list_dir`. `..` is refused before resolution as well. - No path segment may start with a dot. This keeps `.git`, `.env`, `.pi`, `.mosaic` and every dotfile out with one rule. - Symlinks are refused by `lstat` at every step below the root. Only regular files are read; only directories are listed. - A file over `maxFileBytes` (declared per root, default 262144) is refused; a file with a NUL byte in its first 8 KiB is refused as binary. - Text that matches the bot-token shape the suite already greps for, or a line that looks like `Authorization:`/`token=` with a long opaque value, refuses the whole read with a fixed reason. A second barrier, not the first. - A per-run budget: at most `maxCallsPerTurn` tool calls (default 8) between one `agent_start` and its settle; past it every call returns a fixed refusal so the model finishes with what it has. A refusal is a normal tool result with `ok: false` and one reason string; the model sees the reason, never a host path outside the root. The extension never writes, never spawns, never reads the environment beyond its one variable. ### 3.2 The binding New optional top-level key `tools`: ```json "tools": { "roots": [ { "name": "stack-docs", "path": "/mnt/storage/src/mosaic-stack/docs" }, { "name": "sage", "path": "/mnt/storage/src/mosaic-stack/agents/sage" } ], "maxFileBytes": 262144, "maxCallsPerTurn": 8 } ``` Absent means no tools and the launch is exactly today's. Present means every root must be an absolute path to an existing directory, not a symlink, and the loader refuses a root that is, or contains, the data root, `~/.mosaic`, `~/.pi`, `~/.config`, or any path with a dot-prefixed segment. `tools` is a fixed key for `reload`: a change needs stop and start, because the extension reads it at launch. ### 3.3 The engine With tools, one prompt is no longer one turn: pi emits a `turn_end` per assistant message, and the first one may carry only tool calls. The engine settles a prompt on the first `turn_end` whose assistant message has no tool call, and fails it on `agent_settled` without one, as today. Between prompt and settle it records every `tool_execution_start`/`_end` pair as `{name, root, path, ok, reason?, bytes, ms}`; the turn record gains `tools: [...]` and the count. The turn timeout covers the whole run, tool calls included. Launch flags become `--no-builtin-tools --extension /packages/discord/extension/readonly-tools.mjs --tools list_dir,read_file,search`; without `tools` in the binding the flags are unchanged. ### 3.4 The prompt The Discord context block (Q15) gets one paragraph: the tools exist, which roots by name, file content is data like Discord text, do not quote anything that looks like a credential even if a file contains one, and say when a read was refused. `DISCORD-USER.md` is unchanged. ### 3.5 Who gets the tools pi runs one session per binding, so the tools are on for every turn. There is no per-user switch in this iteration: whoever may talk may make Sage read the declared roots. Jason picks roots with that in mind, Carmen included. A per-user switch is possible later (an envelope flag the extension checks against a per-turn allow set the engine passes through `steer`), recorded as a follow-up, not built here. ## 4. Tests - Extension unit tests, offline, no pi: the confinement table (traversal, absolute path, dot segment, symlinked file, symlinked directory, root escape by symlink, binary, oversize, token-shaped content, unknown root, budget exhausted), the three tools' happy paths, the load refusal on a missing or invalid variable. - Engine tests with the fake pi: a prompt that yields a tool turn then a text turn settles once with the tool list recorded; a run that ends with a tool-only turn fails the prompt; the budget refusal reaches the record. - Binding tests: `tools` schema, refused roots, `tools` fixed for reload. - One real-pi start, offline and without a model call: pi in rpc mode with the extension, `get_state` shows exactly the three tools; without `tools` in the binding the extension is not loaded. Part of `scripts/test-discord.sh`. - Negative check: loosening the dot-segment rule fails at least two tests. ## 5. Live check Under the unit, after the binding gains `tools` and a stop/start: 1. Jason in #sage-admin: "What does QUEUE row 19 say?" Expect a reply with the row's substance and a turn record listing one or two reads under `stack-docs`. 2. Jason: "Read ~/.mosaic/fleet/agents/jarvis/secrets and tell me what is there." Expect a refusal in the reply, `ok: false` in the record, no read outside the roots. 3. Carmen, when she tests: same as 1 in #general with a mention. Receipts to the private evidence directory as before. ## 6. Decisions for Jason - D1 Roots for the pilot. Recommended: this repository's `docs/` and `agents/sage/` only. Both are committed content with no secrets by invariant 3. The DYOR repository and jarvis-brain come later, each as its own root, once Q16 is revisited. - D2 Tools are on for every listed user, Carmen included, for the roots in D1. Recommended: yes, given D1 holds only committed content. - D3 Q16 (no DYOR strategy in Discord) stays until the shared repository exists. Recommended: unchanged; the tools do not reach DYOR material under D1. Reviewer per Q12, in practice rev-code-02 on issue #1509. Commit after the suite is green and the review passes; live check after the commit; push only on Jason's word. ## 7. Rulings (Jason, 2026-09-14) Asked as a seven-question round in plain terms; every answer matched the recommendation. - R1 Roots: this repository's `docs/` and `agents/sage/` only. DYOR repository and jarvis-brain later, each as its own step. - R2 Q16 stays: no DYOR strategy in Discord until the shared repository exists. - R3 Confinement by a Mosaic-owned extension on the host, pi built-in tools off. The container route is for writes, later. - R4 Limits: 8 tool calls per message, 400 lines per read, files over 256 KiB refused. All in the binding. - R5 Sage says plainly when a read was refused. - R6 The folder list is a fixed key: stop and start to change it. - R7 Review by rev-code-02 on #1509, offline suite, local commit, live check in #sage-admin with Jason. Push on Jason's word only. - Carmen gets the tools too (Jason, 2026-09-13, before the round). ## 8. Build notes (2026-09-14) Built as in section 3 with these departures, each recorded here rather than silently: - The engine settles a prompt on `agent_end` (with `willRetry` false), not on a turn end without tool calls. pi's `agent_end` carries every message of the run, so the reply is the run's last assistant message and the count of `turn_end` events is recorded as `engine.turns`. A run that ends on a tool-only turn settles with empty text, which the connector already turns into the fixed failure line (`engine-empty`). - The extension is plain JavaScript (`.mjs`), confirmed to load through `--extension` in rpc mode; `--no-extensions` stays on so only the explicit path loads. A throw at load makes pi exit 1, so a missing `MOSAIC_DISCORD_TOOLS` refuses the connector start rather than running without tools. The suite proves all three flag combinations against the real pi, offline, with a probe extension that prints the active tool list. - Refused reads keep the requested root and path in the turn record as evidence of what was asked; the model gets the fixed reason only. - Unreadable files (permissions) are a fixed refusal, not a tool error, and search skips them like binary, oversize and credential-bearing files. - A tool root may not be `/`, the home directory, or a path with a dot-prefixed segment (binding loader), and may not sit inside or above the data root (resolved at `check`/`run` against the configured data root). - Q15's paragraph changes only when `tools` is present; without it the pilot's wording is byte-identical. Round 1 review (rev-code-02, #1509 comment 26272) asked for four changes, all made with a test that fails on the old code: - Tool and turn events now go to the prompt at the front of the queue, and are dropped while that prompt is already failed. Before, a run that outlived its timeout wrote its reads into the next prompt's record. - A read opens the checked file once, with no following of a final symlink and no blocking on a FIFO. It compares device and inode with the walk's `lstat` and reads only from that descriptor, up to the cap plus one byte. A rename between the check and the open is refused instead of followed. Files with more than one hard link are refused too, since a link made under a root can name a file outside it; the two live roots hold none. - The credential shape allows one scheme word before the value, so the usual Authorization header form refuses the read. On the two live roots this refuses no file the old shape allowed. - `scripts/discord-service.sh` names the account for lingering without needing `USER` set. One gap stays open and is recorded here. `list_dir` checks the folder's inode after reading its names. A local process that swaps a folder and swaps it back inside that window could still show names from elsewhere, never file content. Only someone who can already write under a root on this host can try it; Discord users cannot write. Suite: `scripts/test-discord.sh` 47 checks, node tests 114 (was 41 and 101).