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
+1
View File
@@ -253,3 +253,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-13 UTC — coordinator (Claude) — Discord read receipt live check (#1509, QUEUE row 15): reaction confirmed on a live message at 19:21 UTC, turn record receipt ok true, private evidence receipt written, row 15 done. Committed; push on Jason's say-so.
- 2026-09-13 UTC — coordinator (Claude) — Discord service unit (#1509, QUEUE row 17): `run --supervised` and `recover`, exit 3 for brakes, `scripts/discord-service.sh`, systemd user unit; suite 40/40 (95 node tests); Sage seat migrated from tmux to `mosaic-discord@shared-signals`, kill and brake checks passed live. Committed; push on Jason's say-so.
- 2026-09-13 UTC — coordinator (Claude) — Discord binding reload and per-user channels (#1509, QUEUE rows 19–20): `reload`/SIGHUP with fixed keys refused, `reloads.jsonl`, user `channels` allowlist; suite 41/41, 101 node tests; Carmen enrolled live by reload at 00:03 UTC, her first message pending; row 18 assigned to darkwing by Jason.
- 2026-09-14 UTC — coordinator (Claude) — Discord read-only tools (#1509, QUEUE row 21): `tools` binding key, host pi extension `list_dir`/`read_file`/`search` confined to declared roots, engine settles on `agent_end`, tool calls in the turn record; rev-code-02 round 2 APPROVE (26276) after four round 1 findings were fixed; suite 48/48, 116 node tests; committed locally, not pushed; live check in #sage-admin next.
+6 -2
View File
@@ -181,9 +181,13 @@ user entry may carry `channels`, an allowlist of listed channel ids.
`scripts/discord-service.sh install` renders and writes the systemd user
unit `[email protected]` (one instance per binding, restart on
failure, exit 3 never retried, SIGTERM on `systemctl --user stop`, SIGHUP on
`systemctl --user reload`). Records
`systemctl --user reload`). An optional `tools` key in the binding declares
read-only roots; pi then runs with its own tools off and the package's
extension providing `list_dir`, `read_file` and `search` confined to those
roots, with each call in the turn record. `tools` is a fixed key: changing
it needs a stop and start. Records
under `<dataRoot>/discord/<binding>/`: `inbox.jsonl`, `outbox.jsonl`,
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, write-once `turns/<id>.json`. Suite:
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, `reloads.jsonl`, write-once `turns/<id>.json`. Suite:
`scripts/test-discord.sh`.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3
refused by a brake (a supervisor must not retry), 4 usage. Details:
@@ -183,3 +183,75 @@ Receipts to the private evidence directory as before.
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).
+1
View File
@@ -367,3 +367,4 @@ git history + Gitea issues.
- 2026-09-13 — Discord connector pilot for the Sage seat (#1509; QUEUE rows 14–15; brief `2026-09-13_discord-connector-pilot.md`): nine review rounds with rev-code-02, live pilot steps 1–8 with private receipts, Gate H passed (Jason: the replies read as Sage). Commits 786e379c, 788515dc (pushed). MVP iteration 1, eyes reaction as a read receipt, committed 93d6b624 (local); live check pending. Connector stays up in tmux `discord-sage`; binding and token live outside the repo.
- 2026-09-13 — Discord connector iteration 2 (#1509, QUEUE row 17): systemd user service `mosaic-discord@<binding>` with a supervised run that clears a dead lock and never retries a brake (exit 3). Sage seat now runs under systemd, not tmux. Next: control board row.
- 2026-09-13 — Discord connector iterations 4 and 5 (#1509, QUEUE rows 19–20): `reload` verb and `systemctl --user reload` apply channels, users, limits and guildName to the running connector, fixed keys refused, attempts journaled in `reloads.jsonl`; per-user channel allowlist; Carmen enrolled live by a reload at 00:03 UTC (all listed rooms except #sage-admin). Suite 41/41, 101 node tests. Commit caaef941 plus records. Row 18 (board row) is darkwing's by Jason's ruling.
- 2026-09-14 — Discord connector iteration 6 (#1509, QUEUE row 21): read-only tools through a Mosaic pi extension confined to declared roots (Jason's R1–R7: repo `docs/` and `agents/sage/`, every listed user including Carmen, 8 calls a message, 400 lines a read, 256 KiB a file, refusals said plainly, `tools` a fixed key). rev-code-02 approved round 2 (26276). Suite 41 → 48, node tests 101 → 116. Next: live check in #sage-admin, then attachments.
+2 -1
View File
@@ -45,7 +45,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
| 18 | Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused | darkwing (Jason's ruling 2026-09-13); coordinator answers connector-side questions | #1509 | briefed; assigned to darkwing, not started | a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused | `2026-09-13_discord-connector-pilot.md` section 11 |
| 19 | Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4) | coordinator | #1509 | done: caaef941; live 00:03 UTC: reload applied Carmen's entry with no restart, unknown key refused by the CLI (exit 2), fixed key refused in the process with the binding kept, `systemctl --user reload` applied; suite 41/41 (101 node tests); receipt `mvp4-5-reload-carmen-*.json` | edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled | `2026-09-13_discord-connector-pilot.md` section 11 |
| 20 | Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5) | coordinator | #1509 | done: caaef941 (`users[].channels` allowlist, `channel-not-for-user` drop); Carmen enrolled live by reload 00:03 UTC; her first message is the remaining check | Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged | `2026-09-13_discord-connector-pilot.md` section 11 |
| 21 | Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6) | coordinator; reviewer per Q12 | #1509 | briefed; waiting on Jason's D1–D3 (roots, every listed user, Q16) | Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded | `2026-09-14_discord-readonly-tools.md` |
| 21 | Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6) | coordinator; reviewer per Q12 | #1509 | approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b); committed locally; live check in #sage-admin with Jason next; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included) | Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded | `2026-09-14_discord-readonly-tools.md` |
Start message for row 6, sent from the board to darkwing:
"Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on
@@ -94,3 +94,4 @@ Gate F or when blocked."
- 2026-09-13 — coordinator: rows 19 (binding reload) and 20 (per-user channels, Carmen) added on Jason's "proceed in order"; row 19 in progress. No other row changed.
- 2026-09-13 — coordinator: rows 19–20 done (caaef941, live reload 00:03 UTC, Carmen enrolled); Carmen's first message pending as the operator check. No other row changed.
- 2026-09-14 — coordinator: row 21 (read-only tools) briefed on Jason's "let's move to tools"; Carmen's test deferred by Jason; waiting on D1–D3 in the brief. No other row changed.
- 2026-09-14 — coordinator: row 21 rulings in (brief section 7), built and approved by rev-code-02 (26276); suite 48/48, 116 node tests; committed locally, live check next. No other row changed.