feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)

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]>
This commit is contained in:
2026-09-14 19:52:21 -05:00
co-authored by Claude Opus 5
parent c4fc8e7d7f
commit 1ac812d3d5
24 changed files with 1269 additions and 44 deletions
+41 -1
View File
@@ -3,7 +3,8 @@ import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles, reloadDiff } from "../src/binding.mjs";
import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles, resolveToolRoots, reloadDiff, FIXED_KEYS } from "../src/binding.mjs";
import { homedir } from "node:os";
import { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.mjs";
@@ -196,3 +197,42 @@ test("cli: run refuses when STOP is present, before any network use", () => {
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /STOP is present/);
});
test("binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root", () => {
assert.equal(validateBinding(rawBinding()).tools, null);
const root = makeRoot();
const docs = join(root, "docs");
mkdirSync(docs);
const ok = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }] } }));
assert.deepEqual(ok.tools, { roots: [{ name: "docs", path: docs }], maxFileBytes: 262144, maxCallsPerTurn: 8 });
assert.ok(FIXED_KEYS.includes("tools"));
const bad = [
[{ tools: [] }, /must be an object/],
[{ tools: { roots: [] } }, /non-empty/],
[{ tools: { roots: [{ name: "docs", path: "docs" }] } }, /absolute/],
[{ tools: { roots: [{ name: "docs", path: join(root, ".hidden") }] } }, /dot-prefixed/],
[{ tools: { roots: [{ name: "home", path: homedir() }] } }, /home directory/],
[{ tools: { roots: [{ name: "slash", path: "/" }] } }, /filesystem root/],
[{ tools: { roots: [{ name: "docs", path: docs }, { name: "docs", path: docs }] } }, /duplicate/],
[{ tools: { roots: [{ name: "docs", path: docs }], maxCallsPerTurn: 65 } }, /maxCallsPerTurn/],
[{ tools: { roots: [{ name: "docs", path: docs }], extra: true } }, /unknown key/],
[{ tools: { roots: [{ name: "docs", path: docs, mode: "rw" }] } }, /unknown key/],
];
for (const [o, re] of bad) assert.throws(() => validateBinding(rawBinding(o)), re, JSON.stringify(o));
assert.throws(() => reloadDiff(ok, validateBinding(rawBinding())), (e) => e instanceof DiscordError && e.exitCode === 2 && /tools cannot change/.test(e.message));
const dataRoot = join(root, "data");
mkdirSync(join(dataRoot, "discord"), { recursive: true });
assert.equal(resolveToolRoots(validateBinding(rawBinding()), { dataRoot }), null);
const resolved = resolveToolRoots(ok, { dataRoot });
assert.deepEqual(resolved, { roots: [{ name: "docs", path: docs }], maxFileBytes: 262144, maxCallsPerTurn: 8 });
const inData = validateBinding(rawBinding({ tools: { roots: [{ name: "d", path: join(dataRoot, "discord") }] } }));
assert.throws(() => resolveToolRoots(inData, { dataRoot }), /overlaps the data root/);
const above = validateBinding(rawBinding({ tools: { roots: [{ name: "r", path: root }] } }));
assert.throws(() => resolveToolRoots(above, { dataRoot }), /overlaps the data root/);
const missing = validateBinding(rawBinding({ tools: { roots: [{ name: "x", path: join(root, "nope") }] } }));
assert.throws(() => resolveToolRoots(missing, { dataRoot }), /does not exist/);
symlinkSync(docs, join(root, "docs-link"));
const linked = validateBinding(rawBinding({ tools: { roots: [{ name: "l", path: join(root, "docs-link") }] } }));
assert.throws(() => resolveToolRoots(linked, { dataRoot }), /symlink/);
});