Files
stack/docs/plans/2026-09-14_discord-readonly-tools.md
T
jason.woltjeandClaude Opus 5 1ac812d3d5 feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)
A binding may declare `tools` with named roots. pi starts with
--no-builtin-tools and the package's own extension, allowlisting
list_dir, read_file and search. src/tools.mjs holds the rules: names
not paths, per-segment lstat walk, one checked descriptor read that
refuses symlinks, swaps, FIFOs, hard links and oversize files, credential
shapes refusing the whole read, and a per-message call budget. The engine
settles on agent_end and records tool calls in the turn record.

Jason's rulings R1-R7 in the brief, section 7. rev-code-02 approved
round 2 (comment 26276) on tree 43f0329b after four round 1 fixes.
Suite 48/48, node tests 116. Not pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 19:52:21 -05:00

258 lines
13 KiB
Markdown

# 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
<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.
## 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).