Files
stack/packages/discord/src/tools.mjs
T
jason.woltjeandClaude Opus 5.5 20ea5a0b64 fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)
Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").

- binding.mjs derives setspark.approvers from the binding's users (name to
  id); a binding-set approvers key and duplicate names are refused. With
  setspark set, a user id or name change refuses the reload (pi's approvers
  are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
  discord:<id> and refuse unknown names, ids, duplicates and non-lists
  before any request, without echoing the value. hideIds turns mentions,
  discord: values and standalone 17-20 digit runs into the user's name or
  "unknown user" in every verb's text and refusal, including the service
  message and code before they are cut. The connector's approval request
  keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
  a local contract fixture from create through validateRequest, with the
  old name-stored shape still refused.

Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:30:11 -05:00

754 lines
36 KiB
JavaScript

// 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/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
// 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
//
// 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, 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";
import { GIT_TOOL_NAMES, RESERVE_TOOL_NAME, GIT_REFUSAL, GitRefusal, COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES, VAULT_REGISTRY, loadGitConfig, gitStatus, gitCommit, gitPull, gitPush, reserveId, withVaultLock } from "./git.mjs";
import { SETSPARK_TOOL_NAMES, SETSPARK_TOOL_DESCRIPTIONS, SetsparkRefusal, loadSetsparkConfig, setsparkVerbs, renderSetspark, renderRefusal as renderSetsparkRefusal, setsparkDetails } from "./setspark.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:
// the reads always, the writes with a writable root, the web pair with a
// web key, the git verbs with a root that carries a git key, reserve_id
// with a root whose git key names the vault protocol, the SetSpark verbs
// with a setspark key.
export function enabledToolNames(config) {
const names = [...TOOL_NAMES];
const roots = config && Array.isArray(config.roots) ? config.roots : [];
if (roots.some((r) => r.write === true)) names.push(...WRITE_TOOL_NAMES);
if (config && config.web) names.push(...WEB_TOOL_NAMES);
if (roots.some((r) => r.git)) names.push(...GIT_TOOL_NAMES);
if (roots.some((r) => r.git && r.git.protocol === "vault")) names.push(RESERVE_TOOL_NAME);
if (config && config.setspark) names.push(...SETSPARK_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;
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",
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
// 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", "web", "setspark"].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", "write", "git"].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 (r.git !== undefined && r.write !== true) throw new Error(`${w}: git needs write: true`);
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`);
const git = r.git === undefined ? null : loadGitConfig(r.git, `${w}.git`, real);
return Object.freeze({ name: r.name, path: r.path, real, write: r.write === true, git });
});
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),
web: raw.web === undefined ? null : loadWebConfig(raw.web, `${where}.web`),
setspark: raw.setspark === undefined ? null : loadSetsparkConfig(raw.setspark, `${where}.setspark`),
});
}
// 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 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);
withVaultLock(root, target.rel, () => replaceVerified(target, data));
return { root: root.name, path: target.rel, bytes: data.length, created: target.st === null, git: root.git !== 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);
withVaultLock(root, target.rel, () => replaceVerified(target, data));
return { root: root.name, path: target.rel, bytes: data.length, created: false, git: root.git !== null };
}
// --- git verbs: path fencing here, process running in git.mjs ---
function gitRoot(config, name) {
const root = writableRoot(config, name);
if (!root.git) throw new GitRefusal(GIT_REFUSAL.NO_GIT);
return root;
}
// Every commit path goes through the same walk the reads use and must be
// a regular file now; the registry path is allowed by name so a reserved
// id travels with its record.
function commitPaths(root, paths) {
if (!Array.isArray(paths) || paths.length === 0 || paths.length > COMMIT_PATHS_MAX) throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
const rels = paths.map((p) => {
if (typeof p !== "string") throw new GitRefusal(GIT_REFUSAL.BAD_PATHS);
const r = resolveUnder(root, p);
if (!r.st.isFile()) throw new Refusal(REFUSAL.NOT_FILE);
return r.rel;
});
return [...new Set(rels)];
}
// --- the tool set the extension registers: budget plus rendering ---
// The web tools are asynchronous; call() returns a promise for them and a
// plain result for the file tools, and the extension awaits either. The
// git verbs read `state.requester`, which the extension sets from each
// message's envelope before the run starts; the SetSpark writes also read
// the turn id and the author id from there, and the call index from the
// budget counter, to form their idempotency keys.
const SETSPARK_FNS = Object.fromEntries(SETSPARK_TOOL_NAMES.map((name) => [name, (config, params, state) => setsparkVerbs[name](config.setspark, params, state)]));
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),
git_status: (config, { root }) => gitStatus(gitRoot(config, root)),
git_commit: (config, { root, message, paths }, state) => {
const r = gitRoot(config, root);
return gitCommit(r, { message, rels: commitPaths(r, paths), requester: state.requester });
},
git_pull: (config, { root }) => gitPull(gitRoot(config, root)),
git_push: (config, { root }) => gitPush(gitRoot(config, root)),
reserve_id: (config, { root, prefix, title }) => reserveId(gitRoot(config, root), { prefix, title }),
...SETSPARK_FNS,
});
const SETSPARK_SET = new Set(SETSPARK_TOOL_NAMES);
function render(name, out, config) {
if (SETSPARK_SET.has(name)) return renderSetspark(name, out, config.setspark);
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}`;
}
if (name === "write_file" || name === "edit_file") {
const next = out.git ? "not committed yet: commit it with git_commit, naming this path" : "not committed, say which file changed";
return `${out.created ? "created" : "replaced"} ${out.root}/${out.path} (${out.bytes} bytes); ${next}`;
}
if (name === "git_status") {
const lines = [
`${out.root}: branch ${out.branch}${out.upstream ? ` tracking ${out.upstream}` : ""}${out.ahead !== null ? `, ahead ${out.ahead}, behind ${out.behind}` : ""}`,
...out.conflicts.map((p) => `conflict: ${p}`),
...out.changed.map((c) => `changed (${c.state}): ${c.path}`),
...out.untracked.map((p) => `untracked: ${p}`),
];
if (out.changed.length + out.untracked.length + out.conflicts.length === 0) lines.push("clean");
if (out.truncated) lines.push("… list cut short");
return lines.join("\n");
}
if (name === "git_commit") {
const push = out.pushed ? "pushed to origin" : `NOT pushed (${out.pushError}); say so, the next commit retries`;
return `committed ${out.hash} on ${out.branch} for ${out.requester}: ${out.paths.join(", ")}; ${push}`;
}
if (name === "git_pull") {
return out.updated ? `${out.root}: ${out.branch} moved ${out.from} -> ${out.to}` : `${out.root}: ${out.branch} already up to date at ${out.to}`;
}
if (name === "git_push") {
return out.upToDate ? `${out.root}: origin already has ${out.hash}` : `${out.root}: pushed ${out.branch} at ${out.hash} to origin`;
}
if (name === "reserve_id") {
return `reserved ${out.id}; the registry line is in ${out.registry}, stage it with the record${out.note ? ` (${out.note})` : ""}`;
}
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)"}`;
}
// 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 state = { requester: null, turnId: null, authorId: null, callIndex: 0 };
const enabled = new Set(enabledToolNames(config));
const call = (name, params) => {
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
if (!fn) throw new Error(`unknown tool ${name}`);
const t0 = Date.now();
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;
state.callIndex = calls;
const done = (out) => {
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" || SETSPARK_SET.has(name) ? undefined : out.bytes;
const extra = SETSPARK_SET.has(name) ? setsparkDetails(name, out)
: name === "web_fetch" ? { url: out.finalUrl, status: out.status }
: name === "web_search" ? { hits: out.results.length }
: name === "git_commit" ? { hash: out.hash, pushed: out.pushed, paths: out.paths, requester: out.requester }
: name === "git_push" ? { hash: out.hash, pushed: true }
: name === "git_pull" ? { hash: out.to, updated: out.updated }
: name === "git_status" ? { branch: out.branch }
: name === "reserve_id" ? { id: out.id }
: { path: out.path };
return { ok: true, text: render(name, out, config), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
};
const refused = (err) => {
if (err instanceof SetsparkRefusal) {
return { ok: false, text: renderSetsparkRefusal(err, config.setspark), details: { ...base, ok: false, reason: err.reason, ...(err.code ? { code: err.code } : {}), ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
}
if (!(err instanceof Refusal) && !(err instanceof WebRefusal) && !(err instanceof GitRefusal)) throw err;
const reason = err instanceof GitRefusal ? err.message : err.reason;
return { ok: false, text: `refused: ${reason}`, details: { ...base, ok: false, reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
};
try {
const out = fn(config, params || {}, state);
if (out && typeof out.then === "function") return out.then(done, refused);
return done(out);
} catch (err) {
return refused(err);
}
};
return {
call,
resetBudget() {
calls = 0;
},
// The name the binding gives the Discord author of the running message,
// for the commit trailer. null between messages, so a commit outside a
// message is refused.
setRequester(name) {
state.requester = typeof name === "string" && name.length > 0 ? name : null;
},
// The running message's id and author id, from the envelope, for the
// SetSpark write keys and the audit context. Unset between messages, so
// a write outside a message is refused.
setTurn({ requester = null, turnId = null, authorId = null } = {}) {
state.requester = typeof requester === "string" && requester.length > 0 ? requester : null;
state.turnId = typeof turnId === "string" && /^[0-9]{15,20}$/.test(turnId) ? turnId : null;
state.authorId = typeof authorId === "string" && /^[0-9]{15,20}$/.test(authorId) ? authorId : null;
},
get turnId() {
return state.turnId;
},
get requester() {
return state.requester;
},
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",
},
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,
...SETSPARK_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",
},
git_status: {
label: "Git status",
description: "Show the branch, how far it is ahead of or behind origin, and the changed and untracked paths in a root that has git. Read only.",
snippet: "git_status shows the branch and changed paths of a git root",
},
git_commit: {
label: "Git commit",
description: `Stage exactly the named files under a root that has git, commit them as the seat with a trailer naming who asked, and push to origin at once. Name every file you changed (and ${VAULT_REGISTRY} after reserve_id). Message: one to ${COMMIT_MESSAGE_MAX} characters saying what changed and why. Refused when the index already holds other staged work, when a path is locked by another contributor, or when the record validator fails.`,
snippet: "git_commit commits named files as the seat and pushes at once",
},
git_pull: {
label: "Git pull",
description: "Fast-forward the root's branch to origin. Refused, with nothing merged, when origin has diverged or local changes would be overwritten.",
snippet: "git_pull fast-forwards a git root to origin",
},
git_push: {
label: "Git push",
description: "Push the root's branch to origin. git_commit already pushes; use this only when an earlier push was reported as failed.",
snippet: "git_push pushes a git root's branch to origin",
},
reserve_id: {
label: "Reserve record id",
description: `Reserve the next free record id for a prefix (${VAULT_PREFIXES.join(", ")}) in a root that follows the record protocol, before creating the record file. Returns the id; the registry line it appends must be committed with the record.`,
snippet: "reserve_id reserves the next record id before a new record is written",
},
});