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
+430
View File
@@ -0,0 +1,430 @@
// Read-only tools for the Discord Sage, confined to declared roots. This is
// the boundary that decides what a Discord user can make Sage read on this
// host, so it is small, has no dependencies, and is tested without pi.
//
// The extension in ../extension/readonly-tools.mjs registers the three tools
// with pi; every call comes here. Nothing here writes, spawns, or reads the
// environment. A refusal is a normal result with ok=false and one fixed
// reason; the model never sees a host path outside the root it asked for.
//
// Rules, applied before any read, in this order:
// - the root must be one of the declared names; requests carry no
// absolute paths, only a root name and a relative path
// - no `..`, no empty segment, no segment that starts with a dot
// (keeps .git, .env, .pi, .mosaic and every dotfile out with one rule)
// - no symlink anywhere below the root (lstat at every step), and the
// real path must sit under the root's real path
// - only regular files are read, only directories are listed; a file with
// more than one hard link is refused, since a link made under a root can
// name a file outside it
// - the read opens the checked file once, without following a final
// symlink or blocking on a FIFO, confirms by device and inode that the
// descriptor is the file the walk checked, and reads from that
// descriptor only; a rename between the check and the open is refused
// - a file over maxFileBytes, or with a NUL byte in its first 8 KiB, is
// refused as too large or binary
// - content that carries a credential shape refuses the whole read; a
// second barrier behind the roots ruling, not the first
// - at most maxCallsPerTurn calls between one agent_start and the end of
// that run; past it every call is refused with a fixed reason
import { constants, lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, realpathSync } from "node:fs";
import { isAbsolute, join, sep } from "node:path";
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
export const TOOL_DEFAULTS = Object.freeze({ maxFileBytes: 262144, maxCallsPerTurn: 8 });
export const READ_DEFAULT_LINES = 200;
export const READ_MAX_LINES = 400;
export const LIST_MAX_ENTRIES = 200;
export const SEARCH_MAX_HITS = 50;
export const SEARCH_MAX_FILES = 2000;
export const SEARCH_MAX_LINE_CHARS = 200;
const BINARY_PROBE_BYTES = 8192;
export const REFUSAL = Object.freeze({
UNKNOWN_ROOT: "unknown root: use one of the declared root names",
BAD_PATH: "path must be relative, without '..', empty or dot-prefixed segments",
SYMLINK: "symlinks are not followed",
OUTSIDE: "path resolves outside the root",
NOT_FOUND: "no such file or directory under that root",
NOT_FILE: "not a regular file",
NOT_DIR: "not a directory",
TOO_LARGE: "file exceeds the size limit",
BINARY: "file is not text",
CREDENTIAL: "file content looks like a credential; the read is refused",
BUDGET: "tool budget for this message is used up; answer with what you have",
BAD_ARGS: "invalid arguments",
UNREADABLE: "file cannot be read",
CHANGED: "file or folder changed while it was being read",
HARDLINK: "file has more than one hard link",
});
// Shapes that must never reach Discord even if a file under a root holds
// one. The first is the Discord bot token shape scripts/test-discord.sh
// greps the package for; the rest are common credential assignments. The
// third allows one scheme word between the separator and the value, which
// is how an Authorization header is written.
const CREDENTIAL_SHAPES = Object.freeze([
/[A-Za-z0-9_-]{23,28}\.[A-Za-z0-9_-]{6,7}\.[A-Za-z0-9_-]{27,}/,
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
/(?:api[_-]?key|secret|password|passwd|token|authorization)["']?\s*[:=]\s*["']?(?:[A-Za-z]{2,16}\s+)?[A-Za-z0-9._\-+/=]{20,}/i,
/\b(?:sk|ghp|gho|glpat|xox[abp])[-_][A-Za-z0-9_-]{16,}/,
]);
export function looksLikeCredential(text) {
return CREDENTIAL_SHAPES.some((re) => re.test(text));
}
class Refusal extends Error {
constructor(reason) {
super(reason);
this.reason = reason;
}
}
function isObject(v) {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// Validate the configuration the engine hands the extension. Roots are
// resolved once; each must be an absolute path to an existing directory that
// is not a symlink, whose real path has no dot-prefixed segment. Returns a
// frozen config with `real` set on every root.
export function loadToolsConfig(raw, where = TOOLS_ENV) {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["roots", "maxFileBytes", "maxCallsPerTurn"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
}
if (!Array.isArray(raw.roots) || raw.roots.length === 0) throw new Error(`${where}: roots must be a non-empty array`);
const roots = raw.roots.map((r, i) => {
const w = `${where}.roots[${i}]`;
if (!isObject(r)) throw new Error(`${w}: not an object`);
for (const k of Object.keys(r)) {
if (!["name", "path"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
}
if (typeof r.name !== "string" || !ROOT_NAME.test(r.name)) throw new Error(`${w}: name must match ${ROOT_NAME}`);
if (typeof r.path !== "string" || !isAbsolute(r.path) || r.path.includes("\0")) throw new Error(`${w}: path must be an absolute path`);
if (r.path.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: path has a dot-prefixed segment`);
let st;
try {
st = lstatSync(r.path);
} catch {
throw new Error(`${w}: path does not exist: ${r.path}`);
}
if (st.isSymbolicLink()) throw new Error(`${w}: path is a symlink: ${r.path}`);
if (!st.isDirectory()) throw new Error(`${w}: path is not a directory: ${r.path}`);
const real = realpathSync(r.path);
if (real.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: real path has a dot-prefixed segment`);
return Object.freeze({ name: r.name, path: r.path, real });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new Error(`${where}: duplicate root name`);
const merged = { ...TOOL_DEFAULTS, ...raw };
const int = (k, min, max) => {
const v = merged[k];
if (!Number.isInteger(v) || v < min || v > max) throw new Error(`${where}: ${k} must be an integer in ${min}..${max}`);
return v;
};
return Object.freeze({
roots: Object.freeze(roots),
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
});
}
// Split a relative path into segments, refusing anything that could leave
// the root or reach a dotfile. "" means the root itself.
function segments(path) {
if (typeof path !== "string" || path.includes("\0") || isAbsolute(path) || path.startsWith("\\")) throw new Refusal(REFUSAL.BAD_PATH);
const trimmed = path.replace(/\/+$/, "");
if (trimmed === "") return [];
const segs = trimmed.split(/[\\/]/);
for (const s of segs) {
if (s === "" || s === "." || s === ".." || s.startsWith(".")) throw new Refusal(REFUSAL.BAD_PATH);
}
return segs;
}
// Walk from the root's real path one segment at a time with lstat, so a
// symlink at any depth is refused before it is followed. Returns the
// absolute path and its lstat.
export function resolveUnder(root, path) {
const segs = segments(path);
let current = root.real;
let st = lstatSync(current);
for (const s of segs) {
current = join(current, s);
try {
st = lstatSync(current);
} catch {
throw new Refusal(REFUSAL.NOT_FOUND);
}
if (st.isSymbolicLink()) throw new Refusal(REFUSAL.SYMLINK);
}
const real = realpathSync(current);
if (real !== current || (real !== root.real && !real.startsWith(root.real + sep))) throw new Refusal(REFUSAL.OUTSIDE);
return { abs: current, rel: segs.join("/"), st };
}
// Open the path the walk validated, exactly once: no following a final
// symlink (O_NOFOLLOW), no blocking on a FIFO (O_NONBLOCK). The descriptor
// must be the file the walk checked (same device and inode), still a regular
// file with one link; every byte comes from that descriptor, at most
// maxBytes + 1 of them so a file that grew is refused too. A rename of the
// file, or of any folder above it, between the walk and the open lands on a
// different inode and is refused instead of followed.
export function readVerified(abs, st, maxBytes) {
if (typeof constants.O_NOFOLLOW !== "number" || typeof constants.O_NONBLOCK !== "number") {
throw new Error("tools: this platform lacks O_NOFOLLOW or O_NONBLOCK; reads are not safe here");
}
let fd;
try {
fd = openSync(abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (err) {
if (err.code === "ELOOP" || err.code === "EMLINK") throw new Refusal(REFUSAL.SYMLINK);
if (err.code === "ENOENT" || err.code === "ENOTDIR") throw new Refusal(REFUSAL.CHANGED);
throw new Refusal(REFUSAL.UNREADABLE);
}
try {
let fst;
try {
fst = fstatSync(fd);
} catch {
throw new Refusal(REFUSAL.UNREADABLE);
}
if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino) throw new Refusal(REFUSAL.CHANGED);
if (fst.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
const buf = Buffer.allocUnsafe(maxBytes + 1);
let n = 0;
for (;;) {
let r;
try {
r = readSync(fd, buf, n, buf.length - n, n);
} catch {
throw new Refusal(REFUSAL.UNREADABLE);
}
if (r === 0) break;
n += r;
if (n > maxBytes) throw new Refusal(REFUSAL.TOO_LARGE);
}
return buf.subarray(0, n);
} finally {
closeSync(fd);
}
}
// Read a text file under a root with every rule applied. Returns
// {text, bytes} or throws a Refusal.
function readText(root, path, config) {
const { abs, st } = resolveUnder(root, path);
if (!st.isFile()) throw new Refusal(REFUSAL.NOT_FILE);
if (st.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
if (st.size > config.maxFileBytes) throw new Refusal(REFUSAL.TOO_LARGE);
const data = readVerified(abs, st, config.maxFileBytes);
if (data.subarray(0, BINARY_PROBE_BYTES).includes(0)) throw new Refusal(REFUSAL.BINARY);
const text = data.toString("utf8");
if (looksLikeCredential(text)) throw new Refusal(REFUSAL.CREDENTIAL);
return { text, bytes: data.length };
}
function rootByName(config, name) {
const root = typeof name === "string" ? config.roots.find((r) => r.name === name) : undefined;
if (!root) throw new Refusal(REFUSAL.UNKNOWN_ROOT);
return root;
}
function optionalInt(v, name, { min, max, dflt }) {
if (v === undefined || v === null) return dflt;
if (!Number.isInteger(v) || v < min || v > max) throw new Refusal(`${REFUSAL.BAD_ARGS}: ${name} must be an integer in ${min}..${max}`);
return v;
}
// --- the three tools, as pure functions over a config ---
export function listDir(config, { root: rootName, path = "" } = {}) {
const root = rootByName(config, rootName);
const { abs, rel, st } = resolveUnder(root, path);
if (!st.isDirectory()) throw new Refusal(REFUSAL.NOT_DIR);
let names;
try {
names = readdirSync(abs).filter((n) => !n.startsWith(".")).sort();
} catch {
throw new Refusal(REFUSAL.UNREADABLE);
}
// A listing shows names only, never content. Still, confirm the folder
// just read is the one the walk checked; a swap seen here is refused.
let after;
try {
after = lstatSync(abs);
} catch {
throw new Refusal(REFUSAL.CHANGED);
}
if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== st.dev || after.ino !== st.ino) throw new Refusal(REFUSAL.CHANGED);
const entries = [];
for (const n of names) {
if (entries.length >= LIST_MAX_ENTRIES) break;
let est;
try {
est = lstatSync(join(abs, n));
} catch {
continue;
}
if (est.isSymbolicLink()) continue;
if (est.isDirectory()) entries.push({ name: n, type: "dir" });
else if (est.isFile()) entries.push({ name: n, type: "file", bytes: est.size });
}
return { root: root.name, path: rel, entries, truncated: names.length > LIST_MAX_ENTRIES };
}
export function readFile(config, { root: rootName, path, offset, limit } = {}) {
const root = rootByName(config, rootName);
const from = optionalInt(offset, "offset", { min: 1, max: 10000000, dflt: 1 });
const count = optionalInt(limit, "limit", { min: 1, max: READ_MAX_LINES, dflt: READ_DEFAULT_LINES });
const { rel } = resolveUnder(root, path);
const { text, bytes } = readText(root, path, config);
const lines = text.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const slice = lines.slice(from - 1, from - 1 + count);
return { root: root.name, path: rel, bytes, totalLines: lines.length, offset: from, lines: slice };
}
// A fixed-string, case-insensitive search over regular text files under a
// subtree. Files that fail any read rule are skipped, not reported, so a
// refused file cannot leak through a hit line.
export function search(config, { root: rootName, text, path = "" } = {}) {
const root = rootByName(config, rootName);
if (typeof text !== "string" || text.trim().length === 0 || text.length > 200) throw new Refusal(`${REFUSAL.BAD_ARGS}: text must be 1..200 characters`);
const needle = text.toLowerCase();
const start = resolveUnder(root, path);
const hits = [];
let scanned = 0;
let truncated = false;
const visit = (abs, rel) => {
if (truncated) return;
let names;
try {
names = readdirSync(abs).filter((n) => !n.startsWith(".")).sort();
} catch {
return;
}
for (const n of names) {
if (truncated) return;
const childAbs = join(abs, n);
const childRel = rel ? `${rel}/${n}` : n;
let st;
try {
st = lstatSync(childAbs);
} catch {
continue;
}
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) {
visit(childAbs, childRel);
continue;
}
if (!st.isFile()) continue;
if (scanned >= SEARCH_MAX_FILES) {
truncated = true;
return;
}
scanned += 1;
let body;
try {
body = readText(root, childRel, config).text;
} catch {
continue;
}
const lines = body.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!lines[i].toLowerCase().includes(needle)) continue;
if (hits.length >= SEARCH_MAX_HITS) {
truncated = true;
return;
}
hits.push({ path: childRel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_MAX_LINE_CHARS) });
}
}
};
if (start.st.isDirectory()) visit(start.abs, start.rel);
else if (start.st.isFile()) {
scanned = 1;
const body = readText(root, path, config).text;
body.split("\n").forEach((l, i) => {
if (hits.length < SEARCH_MAX_HITS && l.toLowerCase().includes(needle)) hits.push({ path: start.rel, line: i + 1, text: l.trim().slice(0, SEARCH_MAX_LINE_CHARS) });
});
} else throw new Refusal(REFUSAL.NOT_FILE);
return { root: root.name, path: start.rel, text, hits, filesScanned: scanned, truncated };
}
// --- the tool set the extension registers: budget plus rendering ---
const TOOL_FNS = Object.freeze({ list_dir: listDir, read_file: readFile, search });
function render(name, out) {
if (name === "list_dir") {
const head = `${out.root}/${out.path}`.replace(/\/$/, "");
const body = out.entries.map((e) => (e.type === "dir" ? `${e.name}/` : `${e.name} (${e.bytes} bytes)`)).join("\n");
return `${head}:\n${body || "(empty)"}${out.truncated ? `\n… listing cut at ${LIST_MAX_ENTRIES} entries` : ""}`;
}
if (name === "read_file") {
const body = out.lines.map((l, i) => `${out.offset + i}: ${l}`).join("\n");
const end = out.offset + out.lines.length - 1;
return `${out.root}/${out.path} lines ${out.offset}-${end} of ${out.totalLines}\n${body}`;
}
const body = out.hits.map((h) => `${h.path}:${h.line}: ${h.text}`).join("\n");
return `${out.hits.length} hit(s) for ${JSON.stringify(out.text)} under ${out.root}/${out.path || ""} (${out.filesScanned} files)${out.truncated ? ", cut short" : ""}\n${body || "(none)"}`;
}
// A tool set with a per-run budget. `call(name, params)` never throws for a
// policy refusal: it returns {ok, text, details}. Anything else that throws
// is a bug and propagates.
export function createToolSet(config) {
let calls = 0;
const call = (name, params) => {
const fn = TOOL_FNS[name];
if (!fn) throw new Error(`unknown tool ${name}`);
const t0 = Date.now();
const base = { tool: name, root: typeof params?.root === "string" ? params.root.slice(0, 64) : null, path: typeof params?.path === "string" ? params.path.slice(0, 512) : null };
if (calls >= config.maxCallsPerTurn) {
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
}
calls += 1;
try {
const out = fn(config, params || {});
const bytes = name === "read_file" ? out.bytes : undefined;
return { ok: true, text: render(name, out), details: { ...base, ok: true, path: out.path, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
} catch (err) {
if (!(err instanceof Refusal)) throw err;
return { ok: false, text: `refused: ${err.reason}`, details: { ...base, ok: false, reason: err.reason, ms: Date.now() - t0 } };
}
};
return {
call,
resetBudget() {
calls = 0;
},
get calls() {
return calls;
},
};
}
export const TOOL_DESCRIPTIONS = Object.freeze({
list_dir: {
label: "List directory",
description: "List the files and folders directly under a path in one of the declared read-only roots. Paths are relative to the root; dotfiles and symlinks are not shown.",
snippet: "list_dir lists a folder under a declared root",
},
read_file: {
label: "Read file",
description: `Read a window of lines from a text file under one of the declared read-only roots. Default ${READ_DEFAULT_LINES} lines from line 1, at most ${READ_MAX_LINES} per call; use offset to read further. Large, binary, hidden or credential-bearing files are refused.`,
snippet: "read_file reads a text file under a declared root",
},
search: {
label: "Search",
description: `Find lines containing a fixed string (case-insensitive, no regular expressions) in text files under a declared read-only root, optionally within a subfolder. At most ${SEARCH_MAX_HITS} hits.`,
snippet: "search finds a fixed string in files under a declared root",
},
});