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]>
This commit is contained in:
2026-09-14 19:52:21 -05:00
co-authored by Claude Opus 5
parent c4fc8e7d7f
commit 1ac812d3d5
24 changed files with 1269 additions and 44 deletions
+29
View File
@@ -2443,3 +2443,32 @@ seat changed was refused by the process and the binding stayed; the
revert applied with no differences; `systemctl --user reload` applied.
Carmen's own first message is the remaining check. Her id is only in the
binding file.
## 2026-09-14 — Discord read-only tools (#1509, QUEUE row 21)
Before: the Discord Sage ran pi with `--no-tools`; every answer came from
the system prompt and the message alone, and the context block said so.
After: a binding may declare `tools` with named read-only roots. pi then
starts with `--no-builtin-tools`, loads the package's own extension
(`packages/discord/extension/readonly-tools.mjs`) by explicit path and
allowlists exactly `list_dir`, `read_file` and `search`. The rules live in
`src/tools.mjs` and run without pi: root by name only, relative paths,
no `..`, empty or dot-prefixed segments, a per-segment `lstat` walk that
refuses symlinks, real path under the root, regular files only, a size
cap, a NUL check, credential shapes refusing the whole read, and a budget
of calls per message. A refusal is a fixed reason; the model never sees a
host path outside the root. The engine now settles on `agent_end` and
records every tool call in the turn record with its outcome. Without
`tools` nothing changes: the same flags and the same paragraph, byte for
byte. Jason's rulings R1–R7 are in the brief, section 7; the departures
from the design are in section 8. Review: rev-code-02 approved round 2
(26276) after four round 1 findings: late tool events landing in the
next prompt's record, a read-time symlink swap, a missed header form, and
an install message that needed USER set. One non-blocking finding is left
for a later round: the malformed-line handler in `engine-pi.mjs` still
picks the first unfinished prompt, so garbage from a dead run could fail
the next prompt with the fixed engine-protocol line. No record is
corrupted and no boundary is affected. Suite 41 → 48 (three checks run
the real pi offline: the extension exposes exactly three tools, `--no-tools` hides them, a missing
`MOSAIC_DISCORD_TOOLS` makes pi exit), node tests 101 → 116. Not pushed;
the live check in #sage-admin comes after the commit.