diff --git a/docs/plans/2026-09-13_discord-connector-pilot.md b/docs/plans/2026-09-13_discord-connector-pilot.md index 0bef1b88..ef6cdbb8 100644 --- a/docs/plans/2026-09-13_discord-connector-pilot.md +++ b/docs/plans/2026-09-13_discord-connector-pilot.md @@ -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 diff --git a/docs/plans/2026-09-14_discord-readonly-tools.md b/docs/plans/2026-09-14_discord-readonly-tools.md new file mode 100644 index 00000000..d6b40909 --- /dev/null +++ b/docs/plans/2026-09-14_discord-readonly-tools.md @@ -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 +/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. diff --git a/docs/plans/QUEUE.md b/docs/plans/QUEUE.md index a7477fab..3997a7f8 100644 --- a/docs/plans/QUEUE.md +++ b/docs/plans/QUEUE.md @@ -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.