feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)

Row 23. write_file and edit_file for roots marked write: true under the
same fence as reads; web_fetch (https only, public addresses, pinned
connection, capped body) and web_search through SearXNG; extension
renamed to tools.mjs. Engine holds a prompt while pi is busy and sends
it as its own run, so a second message mid-turn no longer folds into
the first (live defect). fake-pi models the real follow-up folding.

Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment
26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG
phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-18 07:27:50 -05:00
co-authored by Claude Fable 5.1
parent 1ac812d3d5
commit 1685deb423
24 changed files with 1519 additions and 113 deletions
+205 -18
View File
@@ -1,11 +1,12 @@
// 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.
// File tools for the Discord Sage, confined to declared roots. This is the
// boundary that decides what a Discord user can make Sage read or write 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.
// The extension in ../extension/tools.mjs registers the enabled tools with
// pi; every call comes here. Nothing here spawns or reads the environment,
// and nothing writes except the two write tools below, only into a root
// marked write: true. 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
@@ -27,11 +28,36 @@
// 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
//
// Writes (row 23, Jason's word 2026-09-16) exist only for roots the binding
// marks `write: true`, and add these rules on top of the read rules:
// - the parent folder must already exist under the root, checked by the
// same symlink-refusing walk; no folder is ever created
// - the target is absent or a regular file with one link; anything else
// (a folder, a FIFO, a symlink, a hard-linked file) is refused
// - the text is at most maxFileBytes, holds no NUL byte, and carries no
// credential shape; a write that would put a secret on disk is refused
// like a read that would show one
// - the bytes go to a dot-prefixed temp file in the same folder, created
// exclusively, then renamed over the target, so a half-written file is
// never visible and the reads (which skip dotfiles) never see the temp
// - edit_file replaces one exact string that occurs exactly once; the
// replaced content goes through the write rules
import { constants, lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, realpathSync } from "node:fs";
import { constants, lstatSync, openSync, fstatSync, readSync, writeSync, closeSync, readdirSync, realpathSync, renameSync, unlinkSync } from "node:fs";
import { isAbsolute, join, sep } from "node:path";
import { randomBytes } from "node:crypto";
import { WEB_TOOL_NAMES, WEB_TOOL_DESCRIPTIONS, FETCH_MAX_TEXT_CHARS, WebRefusal, loadWebConfig, webFetch, webSearch } from "./web.mjs";
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
export const WRITE_TOOL_NAMES = Object.freeze(["write_file", "edit_file"]);
// The tools a config enables, in the order pi's --tools list names them.
export function enabledToolNames(config) {
const names = [...TOOL_NAMES];
if (config && Array.isArray(config.roots) && config.roots.some((r) => r.write === true)) names.push(...WRITE_TOOL_NAMES);
if (config && config.web) names.push(...WEB_TOOL_NAMES);
return names;
}
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
export const TOOL_DEFAULTS = Object.freeze({ maxFileBytes: 262144, maxCallsPerTurn: 8 });
export const READ_DEFAULT_LINES = 200;
@@ -58,6 +84,12 @@ export const REFUSAL = Object.freeze({
UNREADABLE: "file cannot be read",
CHANGED: "file or folder changed while it was being read",
HARDLINK: "file has more than one hard link",
READ_ONLY: "that root is read-only",
NO_PARENT: "the parent folder does not exist under that root",
TARGET: "the target exists and is not a regular file",
NOT_TEXT: "text must be a string without NUL bytes",
EDIT_MATCH: "old text must occur exactly once in the file",
UNWRITABLE: "file cannot be written",
});
// Shapes that must never reach Discord even if a file under a root holds
@@ -96,15 +128,16 @@ const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
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 (!["roots", "maxFileBytes", "maxCallsPerTurn", "web"].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 (!["name", "path", "write"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
}
if (r.write !== undefined && r.write !== true && r.write !== false) throw new Error(`${w}: write must be true or false`);
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`);
@@ -118,7 +151,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
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 });
return Object.freeze({ name: r.name, path: r.path, real, write: r.write === true });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new Error(`${where}: duplicate root name`);
const merged = { ...TOOL_DEFAULTS, ...raw };
@@ -131,6 +164,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
roots: Object.freeze(roots),
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
web: raw.web === undefined ? null : loadWebConfig(raw.web, `${where}.web`),
});
}
@@ -358,9 +392,130 @@ export function search(config, { root: rootName, text, path = "" } = {}) {
return { root: root.name, path: start.rel, text, hits, filesScanned: scanned, truncated };
}
// --- the two write tools, for roots marked write: true ---
function checkText(text, config) {
if (typeof text !== "string" || text.includes("\0")) throw new Refusal(REFUSAL.NOT_TEXT);
const data = Buffer.from(text, "utf8");
if (data.length > config.maxFileBytes) throw new Refusal(REFUSAL.TOO_LARGE);
if (looksLikeCredential(text)) throw new Refusal(REFUSAL.CREDENTIAL);
return data;
}
// Resolve a write target: the parent must exist under the root by the same
// walk the reads use, and the last segment must be absent or a regular
// file with one link. Returns {abs, rel, st} with st null when absent.
function resolveTarget(root, path) {
const segs = segments(path);
if (segs.length === 0) throw new Refusal(REFUSAL.BAD_PATH);
const name = segs[segs.length - 1];
let parent;
try {
parent = resolveUnder(root, segs.slice(0, -1).join("/"));
} catch (err) {
if (err instanceof Refusal && err.reason === REFUSAL.NOT_FOUND) throw new Refusal(REFUSAL.NO_PARENT);
throw err;
}
if (!parent.st.isDirectory()) throw new Refusal(REFUSAL.NO_PARENT);
const abs = join(parent.abs, name);
let st = null;
try {
st = lstatSync(abs);
} catch (err) {
if (err.code !== "ENOENT") throw new Refusal(REFUSAL.UNWRITABLE);
}
if (st !== null) {
if (st.isSymbolicLink()) throw new Refusal(REFUSAL.SYMLINK);
if (!st.isFile()) throw new Refusal(REFUSAL.TARGET);
if (st.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
}
return { abs, rel: segs.join("/"), st, dir: parent.abs };
}
// Put `data` at the resolved target through an exclusive temp file in the
// same folder and one rename. The target is checked again just before the
// rename: a file that appeared, vanished or changed inode in between is
// refused and the temp file removed.
export function replaceVerified(target, data) {
const tmp = join(target.dir, `.mosaic-write-${randomBytes(8).toString("hex")}`);
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
} catch {
throw new Refusal(REFUSAL.UNWRITABLE);
}
try {
let n = 0;
while (n < data.length) {
try {
n += writeSync(fd, data, n, data.length - n);
} catch {
throw new Refusal(REFUSAL.UNWRITABLE);
}
}
closeSync(fd);
fd = undefined;
let now = null;
try {
now = lstatSync(target.abs);
} catch (err) {
if (err.code !== "ENOENT") throw new Refusal(REFUSAL.CHANGED);
}
const same = (target.st === null && now === null)
|| (target.st !== null && now !== null && now.isFile() && now.dev === target.st.dev && now.ino === target.st.ino);
if (!same) throw new Refusal(REFUSAL.CHANGED);
try {
renameSync(tmp, target.abs);
} catch {
throw new Refusal(REFUSAL.UNWRITABLE);
}
} catch (err) {
if (fd !== undefined) closeSync(fd);
try {
unlinkSync(tmp);
} catch {
// the rename already consumed it, or it never existed
}
throw err;
}
}
function writableRoot(config, name) {
const root = rootByName(config, name);
if (!root.write) throw new Refusal(REFUSAL.READ_ONLY);
return root;
}
export function writeFile(config, { root: rootName, path, text } = {}) {
const root = writableRoot(config, rootName);
const data = checkText(text, config);
const target = resolveTarget(root, path);
replaceVerified(target, data);
return { root: root.name, path: target.rel, bytes: data.length, created: target.st === null };
}
export function editFile(config, { root: rootName, path, old, new: replacement } = {}) {
const root = writableRoot(config, rootName);
if (typeof old !== "string" || old.length === 0 || typeof replacement !== "string") throw new Refusal(`${REFUSAL.BAD_ARGS}: old must be a non-empty string and new a string`);
const target = resolveTarget(root, path);
if (target.st === null) throw new Refusal(REFUSAL.NOT_FOUND);
const { text } = readText(root, path, config);
const first = text.indexOf(old);
if (first === -1 || text.indexOf(old, first + old.length) !== -1) throw new Refusal(REFUSAL.EDIT_MATCH);
const data = checkText(text.slice(0, first) + replacement + text.slice(first + old.length), config);
replaceVerified(target, data);
return { root: root.name, path: target.rel, bytes: data.length, created: false };
}
// --- the tool set the extension registers: budget plus rendering ---
const TOOL_FNS = Object.freeze({ list_dir: listDir, read_file: readFile, search });
// The web tools are asynchronous; call() returns a promise for them and a
// plain result for the file tools, and the extension awaits either.
const TOOL_FNS = Object.freeze({
list_dir: listDir, read_file: readFile, search, write_file: writeFile, edit_file: editFile,
web_fetch: (config, params) => webFetch(config.web, params),
web_search: (config, params) => webSearch(config.web, params),
});
function render(name, out) {
if (name === "list_dir") {
@@ -373,6 +528,17 @@ function render(name, out) {
const end = out.offset + out.lines.length - 1;
return `${out.root}/${out.path} lines ${out.offset}-${end} of ${out.totalLines}\n${body}`;
}
if (name === "write_file" || name === "edit_file") {
return `${out.created ? "created" : "replaced"} ${out.root}/${out.path} (${out.bytes} bytes); not committed, say which file changed`;
}
if (name === "web_fetch") {
const head = `${out.finalUrl} (${out.status}, ${out.contentType}, ${out.bytes} bytes${out.truncated ? ", cut at the fetch cap" : ""}${out.redirects ? `, ${out.redirects} redirect(s) from ${out.url}` : ""})`;
return `${head}${out.title ? `\ntitle: ${out.title}` : ""}\n\n${out.text}${out.textTruncated ? `\n… text cut at ${FETCH_MAX_TEXT_CHARS} characters` : ""}`;
}
if (name === "web_search") {
const body = out.results.map((r, i) => `${i + 1}. ${r.title || "(no title)"}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n");
return `${out.results.length} result(s) for "${out.query}"${out.total > out.results.length ? ` (of ${out.total})` : ""}\n${body || "(none)"}`;
}
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)"}`;
}
@@ -382,22 +548,32 @@ function render(name, out) {
// is a bug and propagates.
export function createToolSet(config) {
let calls = 0;
const enabled = new Set(enabledToolNames(config));
const call = (name, params) => {
const fn = TOOL_FNS[name];
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
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 };
const str = (k, max) => (typeof params?.[k] === "string" ? params[k].slice(0, max) : null);
const base = { tool: name, root: str("root", 64), path: str("path", 512), ...(params?.url !== undefined ? { url: str("url", 512) } : {}), ...(params?.query !== undefined ? { query: str("query", 200) } : {}) };
if (calls >= config.maxCallsPerTurn) {
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
}
calls += 1;
const done = (out) => {
const bytes = name === "list_dir" || name === "search" || name === "web_search" ? undefined : out.bytes;
const extra = name === "web_fetch" ? { url: out.finalUrl, status: out.status } : name === "web_search" ? { hits: out.results.length } : { path: out.path };
return { ok: true, text: render(name, out), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
};
const refused = (err) => {
if (!(err instanceof Refusal) && !(err instanceof WebRefusal)) throw err;
return { ok: false, text: `refused: ${err.reason}`, details: { ...base, ok: false, reason: err.reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
};
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 } };
if (out && typeof out.then === "function") return out.then(done, refused);
return done(out);
} 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 refused(err);
}
};
return {
@@ -427,4 +603,15 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
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",
},
write_file: {
label: "Write file",
description: "Create or replace a text file under a root that allows writes. The parent folder must exist; hidden paths, symlinks and credential-bearing text are refused. The file is not committed: tell the user which file changed.",
snippet: "write_file creates or replaces a text file under a writable root",
},
...WEB_TOOL_DESCRIPTIONS,
edit_file: {
label: "Edit file",
description: "Replace one exact string that occurs exactly once in a text file under a root that allows writes. Read the file first so the old text is exact. The file is not committed: tell the user which file changed.",
snippet: "edit_file replaces one exact string in a file under a writable root",
},
});