Files
stack/docs/plans/2026-09-14_discord-readonly-tools.md
T

8.9 KiB

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:

"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 <repo>/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.