docs(discord): brief read-only tools for the Discord Sage as row 21, waiting on D1–D3 (#1509)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-13 19:33:45 -05:00
co-authored by Claude Fable 5.1
parent 1267e6a0a0
commit c4fc8e7d7f
3 changed files with 188 additions and 0 deletions
@@ -462,6 +462,7 @@ Recorded here because they refine a ruling or fill a gap the rulings left.
edit plus `reload`, which doubles as the live check for iteration 4.
Carmen's id lives only in the binding file, never in the repository.
- Live check 2026-09-13 00:03 UTC (iterations 4 and 5): unit reinstalled with `ExecReload` and soft-restarted, READY on the new code. Carmen added to the binding with every listed channel except #sage-admin and applied by `reload` (users +1, no restart). An unknown key was refused by the CLI (exit 2, nothing signaled); a valid file with `seat` changed was refused in the process and the binding stayed; the revert applied with no differences; `systemctl --user reload` applied. Receipt `mvp4-5-reload-carmen-*.json` and a 0600 backup of the binding before the edit in the private evidence dir. Remaining: Carmen's first message in #general (reply, eyes reaction) and one in #sage-admin (silence, `channel-not-for-user` drop line).
- Read-only tools (iteration 6, Jason: "let's move to tools", 2026-09-13; Carmen's test deferred). Own brief: `2026-09-14_discord-readonly-tools.md`. Waiting on D1D3 there.
- Control board row (iteration 3, briefed, not started). Blocked on
ownership, not on design: `packages/control-board` is darkwing's (#1503,
#1505, brief `2026-09-12_control-board-mvp.md`), and the row cannot be
@@ -0,0 +1,185 @@
# 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.
+2
View File
@@ -45,6 +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 D1D3 (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` |
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
@@ -92,3 +93,4 @@ Gate F or when blocked."
- 2026-09-13 — coordinator: row 17 operator check passed (Jason: all tests successfully verified, 21:29 UTC). No other row changed.
- 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 1920 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 D1D3 in the brief. No other row changed.