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:
@@ -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 D1–D3 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.
|
||||
@@ -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 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` |
|
||||
|
||||
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 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.
|
||||
|
||||
Reference in New Issue
Block a user