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]>
13 KiB
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:
- Run pi for Discord inside the existing Mosaic container the way
scripts/agent.shandscripts/run-task.shdo, 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. - 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:
rootmust 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 forlist_dir...is refused before resolution as well. - No path segment may start with a dot. This keeps
.git,.env,.pi,.mosaicand every dotfile out with one rule. - Symlinks are refused by
lstatat 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
maxCallsPerTurntool calls (default 8) between oneagent_startand 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:
"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:
toolsschema, refused roots,toolsfixed for reload. - One real-pi start, offline and without a model call: pi in rpc mode
with the extension,
get_stateshows exactly the three tools; withouttoolsin the binding the extension is not loaded. Part ofscripts/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:
- 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. - Jason: "Read ~/.mosaic/fleet/agents/jarvis/secrets and tell me what is
there." Expect a refusal in the reply,
ok: falsein the record, no read outside the roots. - 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/andagents/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/andagents/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(withwillRetryfalse), not on a turn end without tool calls. pi'sagent_endcarries every message of the run, so the reply is the run's last assistant message and the count ofturn_endevents is recorded asengine.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--extensionin rpc mode;--no-extensionsstays on so only the explicit path loads. A throw at load makes pi exit 1, so a missingMOSAIC_DISCORD_TOOLSrefuses 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 atcheck/runagainst the configured data root). - Q15's paragraph changes only when
toolsis 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
lstatand 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.shnames the account for lingering without needingUSERset.
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).