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
+58 -2
View File
@@ -12,11 +12,16 @@
// absent means every listed channel. A running connector may re-read the
// file (`reload`): `reloadDiff` says which keys may change in place and
// refuses the rest.
//
// An optional `tools` key declares read-only roots for the Discord Sage's
// tools (see tools.mjs). Absent means no tools and a launch exactly as
// before. It is a fixed key: the extension reads it at pi start.
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { isAbsolute, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import { DiscordError } from "./errors.mjs";
import { TOOL_DEFAULTS } from "./tools.mjs";
export const BINDING_VERSION = 1;
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
@@ -31,12 +36,15 @@ export const LIMIT_DEFAULTS = Object.freeze({
inboundMaxChars: 4000,
});
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context"];
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context", "tools"];
const CHANNEL_KEYS = ["id", "name", "mode"];
const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"];
const TOOLS_KEYS = ["roots", "maxFileBytes", "maxCallsPerTurn"];
const ROOT_KEYS = ["name", "path"];
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export function defaultConfigPath(env = process.env) {
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
@@ -172,6 +180,31 @@ export function validateBinding(raw, where = "binding") {
return f;
});
let tools = null;
if (raw.tools !== undefined) {
if (!isObject(raw.tools)) throw new DiscordError(`${where}: tools must be an object`);
onlyKeys(raw.tools, TOOLS_KEYS, `${where}.tools`);
if (!Array.isArray(raw.tools.roots) || raw.tools.roots.length === 0) throw new DiscordError(`${where}.tools: roots must be a non-empty array`);
const roots = raw.tools.roots.map((r, i) => {
const w = `${where}.tools.roots[${i}]`;
if (!isObject(r)) throw new DiscordError(`${w}: not an object`);
onlyKeys(r, ROOT_KEYS, w);
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
const rpath = requireString(r, "path", w);
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
return Object.freeze({ name: rname, path: rpath });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new DiscordError(`${where}.tools: duplicate root name`);
const mergedTools = { ...TOOL_DEFAULTS, ...raw.tools, roots };
tools = Object.freeze({
roots: Object.freeze(roots),
maxFileBytes: requireInteger(mergedTools, "maxFileBytes", `${where}.tools`, { min: 1024, max: 4 * 1024 * 1024 }),
maxCallsPerTurn: requireInteger(mergedTools, "maxCallsPerTurn", `${where}.tools`, { min: 1, max: 64 }),
});
}
return Object.freeze({
bindingVersion: BINDING_VERSION,
name, seat, guildId, guildName, botUserId, tokenFile,
@@ -180,6 +213,7 @@ export function validateBinding(raw, where = "binding") {
engine: Object.freeze({ provider, model, thinking }),
limits,
context: Object.freeze({ files: Object.freeze(files) }),
tools,
});
}
@@ -189,7 +223,7 @@ export function validateBinding(raw, where = "binding") {
// key needs a stop and a start. Returns a summary of the reloadable
// differences or throws with exit 2.
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context"]);
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context", "tools"]);
export function reloadDiff(current, next) {
for (const k of FIXED_KEYS) {
@@ -274,3 +308,25 @@ export function resolveContextFiles(binding, repo) {
return path;
});
}
// Tool roots must exist as real directories on this host, not symlinks, and
// must not sit inside the data root (bindings, tokens, journals) or contain
// it. Returns the resolved config the engine hands the extension.
export function resolveToolRoots(binding, { dataRoot }) {
if (!binding.tools) return null;
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
const roots = binding.tools.roots.map((r) => {
let st;
try {
st = lstatSync(r.path);
} catch {
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
}
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
const real = realpathSync(r.path);
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
return { name: r.name, path: real };
});
return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn };
}