// 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", }, });