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]>
258 lines
13 KiB
Markdown
258 lines
13 KiB
Markdown
# 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.
|
|
|
|
## 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).
|