// The file tools' confinement, tested without pi. Every row here is a // way a Discord user could try to make Sage read outside the declared // roots, and the fixed refusal it gets instead. import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, chmodSync, linkSync, lstatSync, renameSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { loadToolsConfig, createToolSet, enabledToolNames, listDir, readFile, search, writeFile, editFile, replaceVerified, looksLikeCredential, readVerified, REFUSAL, TOOL_NAMES, WRITE_TOOL_NAMES, LIST_MAX_ENTRIES, SEARCH_MAX_HITS } from "../src/tools.mjs"; import { makeRoot } from "./helpers.mjs"; // Built at run time so the suite's grep for a bot-token shape never finds // one in the source tree. const FAKE_BOT_TOKEN = ["M", "TAw".repeat(9), ".", "GaBcDe", ".", "abcdefghijklmnopqrstuvwxyz0123456789ABC"].join(""); // A made-up 22-character opaque value for header and assignment forms. const OPAQUE = ["Zm9v", "YmFy", "YmF6", "cXV4", "cXV1eA"].join(""); // A root with a nested tree, a dotfile, a binary, an oversize file, a // credential-bearing file, and symlinks pointing inside and outside. function fixture() { const base = makeRoot(); const root = join(base, "docs"); const outside = join(base, "outside"); mkdirSync(join(root, "plans"), { recursive: true }); mkdirSync(join(root, ".hidden")); mkdirSync(outside); writeFileSync(join(root, "README.md"), "# Docs\n\nhello world\nsecond line\n"); writeFileSync(join(root, "plans", "QUEUE.md"), "row 1\nrow 2 Hello\nrow 3\n"); writeFileSync(join(root, ".env"), "SECRET=x\n"); writeFileSync(join(root, ".hidden", "note.md"), "hidden\n"); writeFileSync(join(root, "blob.bin"), Buffer.from([0x41, 0x00, 0x42])); writeFileSync(join(root, "big.md"), "x".repeat(5000)); writeFileSync(join(root, "leak.md"), `token = ${FAKE_BOT_TOKEN}\n`); writeFileSync(join(outside, "secret.txt"), "not for discord\n"); symlinkSync(join(outside, "secret.txt"), join(root, "link-out.md")); symlinkSync(outside, join(root, "dir-out")); symlinkSync(join(root, "README.md"), join(root, "link-in.md")); symlinkSync(root, join(base, "docs-link")); return { base, root, outside }; } function config(root, extra = {}) { return loadToolsConfig({ roots: [{ name: "docs", path: root }], maxFileBytes: 4096, maxCallsPerTurn: 3, ...extra }); } test("tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits", () => { const { base, root } = fixture(); assert.throws(() => loadToolsConfig(null), /not an object/); assert.throws(() => loadToolsConfig({ roots: [] }), /non-empty/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: "docs" }] }), /absolute/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: join(base, "nope") }] }), /does not exist/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: join(base, "docs-link") }] }), /symlink/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: join(root, "README.md") }] }), /not a directory/); assert.throws(() => loadToolsConfig({ roots: [{ name: "h", path: join(root, ".hidden") }] }), /dot-prefixed/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: root }, { name: "docs", path: root }] }), /duplicate/); assert.throws(() => loadToolsConfig({ roots: [{ name: "Docs", path: root }] }), /name must match/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 0 }), /maxCallsPerTurn/); assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: root }], extra: 1 }), /unknown key/); const c = config(root); assert.equal(c.roots[0].real, root); assert.equal(c.maxFileBytes, 4096); }); test("tools: every escape is refused with a fixed reason and nothing outside the root is read", () => { const { root } = fixture(); const c = config(root); const rows = [ [readFile, { root: "nope", path: "README.md" }, REFUSAL.UNKNOWN_ROOT], [readFile, { root: "docs", path: "/etc/passwd" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: "../outside/secret.txt" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: "plans/../../outside/secret.txt" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: ".env" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: ".hidden/note.md" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: "plans//QUEUE.md" }, REFUSAL.BAD_PATH], [readFile, { root: "docs", path: "link-out.md" }, REFUSAL.SYMLINK], [readFile, { root: "docs", path: "link-in.md" }, REFUSAL.SYMLINK], [readFile, { root: "docs", path: "dir-out/secret.txt" }, REFUSAL.SYMLINK], [listDir, { root: "docs", path: "dir-out" }, REFUSAL.SYMLINK], [readFile, { root: "docs", path: "missing.md" }, REFUSAL.NOT_FOUND], [readFile, { root: "docs", path: "plans" }, REFUSAL.NOT_FILE], [listDir, { root: "docs", path: "README.md" }, REFUSAL.NOT_DIR], [readFile, { root: "docs", path: "blob.bin" }, REFUSAL.BINARY], [readFile, { root: "docs", path: "big.md" }, REFUSAL.TOO_LARGE], [readFile, { root: "docs", path: "leak.md" }, REFUSAL.CREDENTIAL], [readFile, { root: "docs", path: "README.md", limit: 401 }, /limit must be an integer/], [search, { root: "docs", text: "" }, /text must be/], [search, { root: "docs", text: "x", path: "../outside" }, REFUSAL.BAD_PATH], ]; for (const [fn, params, want] of rows) { assert.throws(() => fn(c, params), (err) => (want instanceof RegExp ? want.test(err.reason) : err.reason === want), `${fn.name} ${JSON.stringify(params)}`); } const set = createToolSet(c); const r = set.call("read_file", { root: "docs", path: "../outside/secret.txt" }); assert.equal(r.ok, false); assert.equal(r.text, `refused: ${REFUSAL.BAD_PATH}`); assert.equal(r.details.reason, REFUSAL.BAD_PATH); assert.ok(!r.text.includes("outside"), "the model gets the reason only"); assert.equal(r.details.path, "../outside/secret.txt", "the record keeps what was asked for, as evidence"); }); test("tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear", () => { const { root } = fixture(); const c = config(root); const ls = listDir(c, { root: "docs" }); assert.deepEqual(ls.entries.map((e) => e.name), ["README.md", "big.md", "blob.bin", "leak.md", "plans"]); assert.equal(ls.entries.find((e) => e.name === "plans").type, "dir"); const rd = readFile(c, { root: "docs", path: "README.md", offset: 3, limit: 1 }); assert.deepEqual(rd, { root: "docs", path: "README.md", bytes: 32, totalLines: 4, offset: 3, lines: ["hello world"] }); const whole = readFile(c, { root: "docs", path: "plans/QUEUE.md" }); assert.equal(whole.lines.length, 3); const hits = search(c, { root: "docs", text: "HELLO" }); assert.deepEqual(hits.hits, [ { path: "README.md", line: 3, text: "hello world" }, { path: "plans/QUEUE.md", line: 2, text: "row 2 Hello" }, ]); assert.equal(hits.filesScanned, 5, "big, binary and credential files are scanned and skipped, never reported"); const scoped = search(c, { root: "docs", text: "hello", path: "plans" }); assert.equal(scoped.hits.length, 1); const one = search(c, { root: "docs", text: "row", path: "plans/QUEUE.md" }); assert.equal(one.hits.length, 3); const leak = search(c, { root: "docs", text: "token" }); assert.equal(leak.hits.length, 0, "a credential-bearing file yields no hit lines"); }); test("tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget", () => { const { root } = fixture(); const set = createToolSet(config(root)); const a = set.call("list_dir", { root: "docs", path: "plans" }); assert.equal(a.ok, true); assert.match(a.text, /^docs\/plans:\nQUEUE\.md \(24 bytes\)$/); const b = set.call("read_file", { root: "docs", path: "README.md", limit: 2 }); assert.equal(b.text, "docs/README.md lines 1-2 of 4\n1: # Docs\n2: "); assert.deepEqual({ ...b.details, ms: 0 }, { tool: "read_file", root: "docs", path: "README.md", ok: true, bytes: 32, ms: 0 }); const s = set.call("search", { root: "docs", text: "row 3" }); assert.match(s.text, /^1 hit\(s\) for "row 3" under docs\/ \(5 files\)\nplans\/QUEUE\.md:3: row 3$/); assert.equal(set.calls, 3); const over = set.call("read_file", { root: "docs", path: "README.md" }); assert.equal(over.ok, false); assert.equal(over.details.reason, REFUSAL.BUDGET); assert.equal(set.calls, 3, "a budget refusal does not count"); set.resetBudget(); assert.equal(set.call("read_file", { root: "docs", path: "README.md" }).ok, true); assert.throws(() => set.call("bash", {}), /unknown tool/); assert.throws(() => set.call("write_file", { root: "docs", path: "x.md", text: "x" }), /unknown tool/, "no writable root: the write tools are not even names"); assert.deepEqual(TOOL_NAMES, ["list_dir", "read_file", "search"]); assert.deepEqual(WRITE_TOOL_NAMES, ["write_file", "edit_file"]); assert.deepEqual(enabledToolNames(config(root)), ["list_dir", "read_file", "search"]); }); test("tools: listing and search caps hold", () => { const base = makeRoot(); const root = join(base, "many"); mkdirSync(root); for (let i = 0; i < LIST_MAX_ENTRIES + 5; i += 1) writeFileSync(join(root, `f${String(i).padStart(4, "0")}.md`), "needle\n"); const c = loadToolsConfig({ roots: [{ name: "many", path: root }] }); const ls = listDir(c, { root: "many" }); assert.equal(ls.entries.length, LIST_MAX_ENTRIES); assert.equal(ls.truncated, true); const s = search(c, { root: "many", text: "needle" }); assert.equal(s.hits.length, SEARCH_MAX_HITS); assert.equal(s.truncated, true); }); test("tools: credential shapes are caught; ordinary prose and ids are not", () => { assert.equal(looksLikeCredential(FAKE_BOT_TOKEN), true); assert.equal(looksLikeCredential("-----BEGIN RSA PRIVATE KEY-----"), true); assert.equal(looksLikeCredential('api_key: "abcdefghijklmnopqrstuvwxyz"'), true); assert.equal(looksLikeCredential("Authorization = Bearer0123456789abcdefghijk"), true); assert.equal(looksLikeCredential("ghp_abcdefghijklmnopqrstuvwxyz0123"), true); assert.equal(looksLikeCredential("The token is read once; it is never printed."), false); assert.equal(looksLikeCredential("user 100000000000000100 in channel 100000000000000011"), false); assert.equal(looksLikeCredential("password: (see the seat's private file)"), false); assert.equal(looksLikeCredential(`Authorization: Bearer ${OPAQUE}`), true, "header form with a scheme word"); assert.equal(looksLikeCredential(`authorization = basic ${OPAQUE}`), true); assert.equal(looksLikeCredential(`TOKEN="${OPAQUE}"`), true, "assignment form"); assert.equal(looksLikeCredential("Authorization: Bearer (read from the seat's private file at run time)"), false); const base = makeRoot(); const root = join(base, "hdr"); mkdirSync(root); writeFileSync(join(root, "notes.md"), `curl -H "Authorization: Bearer ${OPAQUE}"\n`); const c = loadToolsConfig({ roots: [{ name: "hdr", path: root }] }); assert.throws(() => readFile(c, { root: "hdr", path: "notes.md" }), (err) => err.reason === REFUSAL.CREDENTIAL); assert.equal(search(c, { root: "hdr", text: "curl" }).hits.length, 0); }); test("tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused", () => { const { base, root, outside } = fixture(); const c = config(root); const readme = join(root, "README.md"); const checked = lstatSync(readme); assert.equal(readVerified(readme, checked, 4096).toString("utf8"), "# Docs\n\nhello world\nsecond line\n"); // The checked name became a symlink to a file outside the root. const swapLink = join(base, "swap-link.md"); symlinkSync(join(outside, "secret.txt"), swapLink); assert.throws(() => readVerified(swapLink, checked, 4096), (err) => err.reason === REFUSAL.SYMLINK); // The checked name now holds a different regular file (a rename over it). assert.throws(() => readVerified(join(outside, "secret.txt"), checked, 4096), (err) => err.reason === REFUSAL.CHANGED); // A real rename over the checked path, the race rev-code-02 reproduced. const victim = join(root, "plans", "QUEUE.md"); const victimSt = lstatSync(victim); const planted = join(root, "plans", "planted.md"); symlinkSync(join(outside, "secret.txt"), planted); renameSync(planted, victim); assert.throws(() => readVerified(victim, victimSt, 4096), (err) => err.reason === REFUSAL.SYMLINK); // A FIFO under the checked name: refused at once, never a hang. const fifo = join(base, "fifo"); if (spawnSync("mkfifo", [fifo]).status === 0) { assert.throws(() => readVerified(fifo, checked, 4096), (err) => err.reason === REFUSAL.CHANGED); } // A file that grew past the cap after its size was checked. const big = join(root, "big.md"); assert.throws(() => readVerified(big, lstatSync(big), 4096), (err) => err.reason === REFUSAL.TOO_LARGE); // A hard link made under the root to a file outside it. linkSync(join(outside, "secret.txt"), join(root, "hard.md")); assert.throws(() => readFile(c, { root: "docs", path: "hard.md" }), (err) => err.reason === REFUSAL.HARDLINK); assert.equal(search(c, { root: "docs", text: "not for discord" }).hits.length, 0); }); test("tools: an unreadable file under the root is skipped by search and refused by read", () => { if (process.getuid && process.getuid() === 0) return; const { root } = fixture(); writeFileSync(join(root, "plans", "locked.md"), "hello\n"); chmodSync(join(root, "plans", "locked.md"), 0o000); const c = config(root); assert.equal(search(c, { root: "docs", text: "hello", path: "plans" }).hits.length, 1); assert.throws(() => readFile(c, { root: "docs", path: "plans/locked.md" }), (err) => err.reason === REFUSAL.UNREADABLE); }); // --- writes (row 23): only into a root marked write: true --- // Two roots: "docs" read-only as above, "vault" writable, with a dotted // folder, a symlinked folder, a folder and a hard link to trip over. function writeFixture() { const { base, root, outside } = fixture(); const vault = join(base, "vault"); mkdirSync(join(vault, "Businesses"), { recursive: true }); mkdirSync(join(vault, ".git")); writeFileSync(join(vault, "Home.md"), "# Home\n\nold line\nold line\n"); writeFileSync(join(vault, "Businesses", "DYOR.md"), "# DYOR\n\nname: tbd\n"); writeFileSync(join(outside, "target.md"), "outside\n"); symlinkSync(outside, join(vault, "dir-out")); symlinkSync(join(outside, "target.md"), join(vault, "link-out.md")); linkSync(join(outside, "target.md"), join(vault, "hard.md")); const c = loadToolsConfig({ roots: [{ name: "docs", path: root }, { name: "vault", path: vault, write: true }], maxFileBytes: 4096, maxCallsPerTurn: 6, }); return { base, root, vault, outside, c }; } const noTemp = (dir) => assert.deepEqual(readdirSync(dir).filter((n) => n.startsWith(".mosaic-write-")), [], `no temp file left in ${dir}`); test("tools: config accepts write: true only as a boolean, and enables the write tools only then", () => { const { root, vault, c } = writeFixture(); assert.equal(c.roots[0].write, false); assert.equal(c.roots[1].write, true); assert.deepEqual(enabledToolNames(c), ["list_dir", "read_file", "search", "write_file", "edit_file"]); assert.throws(() => loadToolsConfig({ roots: [{ name: "v", path: vault, write: "yes" }] }), /write must be true or false/); assert.throws(() => loadToolsConfig({ roots: [{ name: "v", path: vault, write: 1 }] }), /write must be true or false/); assert.deepEqual(enabledToolNames(loadToolsConfig({ roots: [{ name: "docs", path: root, write: false }] })), TOOL_NAMES); }); test("tools: every write outside the fence is refused before any byte lands, and no temp file remains", () => { const { base, vault, outside, c } = writeFixture(); const secret = `token = ${FAKE_BOT_TOKEN}\n`; const rows = [ [writeFile, { root: "docs", path: "new.md", text: "x" }, REFUSAL.READ_ONLY], [writeFile, { root: "nope", path: "new.md", text: "x" }, REFUSAL.UNKNOWN_ROOT], [writeFile, { root: "vault", path: "", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: "../outside/new.md", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: "/tmp/new.md", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: ".git/config", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: ".env", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: "Businesses/.mosaic-write-x", text: "x" }, REFUSAL.BAD_PATH], [writeFile, { root: "vault", path: "Missing/new.md", text: "x" }, REFUSAL.NO_PARENT], [writeFile, { root: "vault", path: "Home.md/new.md", text: "x" }, REFUSAL.NO_PARENT], [writeFile, { root: "vault", path: "dir-out/new.md", text: "x" }, REFUSAL.SYMLINK], [writeFile, { root: "vault", path: "link-out.md", text: "x" }, REFUSAL.SYMLINK], [writeFile, { root: "vault", path: "hard.md", text: "x" }, REFUSAL.HARDLINK], [writeFile, { root: "vault", path: "Businesses", text: "x" }, REFUSAL.TARGET], [writeFile, { root: "vault", path: "big.md", text: "x".repeat(4097) }, REFUSAL.TOO_LARGE], [writeFile, { root: "vault", path: "leak.md", text: secret }, REFUSAL.CREDENTIAL], [writeFile, { root: "vault", path: "nul.md", text: "a\0b" }, REFUSAL.NOT_TEXT], [writeFile, { root: "vault", path: "num.md", text: 5 }, REFUSAL.NOT_TEXT], [editFile, { root: "docs", path: "README.md", old: "hello", new: "bye" }, REFUSAL.READ_ONLY], [editFile, { root: "vault", path: "Missing.md", old: "a", new: "b" }, REFUSAL.NOT_FOUND], [editFile, { root: "vault", path: "Home.md", old: "", new: "b" }, /old must be/], [editFile, { root: "vault", path: "Home.md", old: "absent", new: "b" }, REFUSAL.EDIT_MATCH], [editFile, { root: "vault", path: "Home.md", old: "old line", new: "b" }, REFUSAL.EDIT_MATCH], [editFile, { root: "vault", path: "Home.md", old: "# Home", new: secret }, REFUSAL.CREDENTIAL], [editFile, { root: "vault", path: "Home.md", old: "# Home", new: "x".repeat(4097) }, REFUSAL.TOO_LARGE], [editFile, { root: "vault", path: "link-out.md", old: "outside", new: "in" }, REFUSAL.SYMLINK], ]; if (spawnSync("mkfifo", [join(vault, "fifo.md")]).status === 0) { rows.push([writeFile, { root: "vault", path: "fifo.md", text: "x" }, REFUSAL.TARGET]); } for (const [fn, params, want] of rows) { assert.throws(() => fn(c, params), (err) => (want instanceof RegExp ? want.test(err.reason) : err.reason === want), `${fn.name} ${JSON.stringify(params)}`); } assert.equal(readFileSync(join(outside, "target.md"), "utf8"), "outside\n", "nothing outside changed"); assert.equal(readFileSync(join(vault, "Home.md"), "utf8"), "# Home\n\nold line\nold line\n", "a refused edit leaves the file alone"); for (const d of [vault, join(vault, "Businesses"), outside, base]) noTemp(d); assert.deepEqual(readdirSync(join(vault, ".git")), []); }); test("tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted", () => { const { vault, c } = writeFixture(); const text = "# Names\n\n- one\n- two\n\u00e9\n"; const w = writeFile(c, { root: "vault", path: "Businesses/Names.md", text }); assert.deepEqual(w, { root: "vault", path: "Businesses/Names.md", bytes: Buffer.byteLength(text), created: true }); assert.equal(readFileSync(join(vault, "Businesses", "Names.md"), "utf8"), text); assert.equal((lstatSync(join(vault, "Businesses", "Names.md")).mode & 0o777) <= 0o644, true); const w2 = writeFile(c, { root: "vault", path: "Businesses/Names.md", text: "- three\n" }); assert.equal(w2.created, false); assert.equal(readFileSync(join(vault, "Businesses", "Names.md"), "utf8"), "- three\n"); const e = editFile(c, { root: "vault", path: "Businesses/DYOR.md", old: "name: tbd", new: "name: chosen" }); assert.deepEqual(e, { root: "vault", path: "Businesses/DYOR.md", bytes: 21, created: false }); assert.equal(readFileSync(join(vault, "Businesses", "DYOR.md"), "utf8"), "# DYOR\n\nname: chosen\n"); const set = createToolSet(c); const r = set.call("write_file", { root: "vault", path: "Note.md", text: "hi\n" }); assert.equal(r.ok, true); assert.equal(r.text, "created vault/Note.md (3 bytes); not committed, say which file changed"); assert.deepEqual({ ...r.details, ms: 0 }, { tool: "write_file", root: "vault", path: "Note.md", ok: true, bytes: 3, ms: 0 }); const r2 = set.call("edit_file", { root: "vault", path: "Note.md", old: "hi", new: "hello" }); assert.equal(r2.text, "replaced vault/Note.md (6 bytes); not committed, say which file changed"); const refused = set.call("write_file", { root: "docs", path: "Note.md", text: "hi\n" }); assert.equal(refused.ok, false); assert.equal(refused.text, `refused: ${REFUSAL.READ_ONLY}`); assert.equal(set.calls, 3); noTemp(vault); noTemp(join(vault, "Businesses")); }); test("tools: a target that changed between the check and the rename is refused and the temp file is removed", () => { const { vault, outside } = writeFixture(); const home = join(vault, "Home.md"); const stale = lstatSync(join(outside, "target.md")); assert.throws(() => replaceVerified({ abs: home, dir: vault, st: stale }, Buffer.from("x")), (err) => err.reason === REFUSAL.CHANGED); assert.equal(readFileSync(home, "utf8"), "# Home\n\nold line\nold line\n"); assert.throws(() => replaceVerified({ abs: home, dir: vault, st: null }, Buffer.from("x")), (err) => err.reason === REFUSAL.CHANGED, "checked as absent, now present"); assert.throws(() => replaceVerified({ abs: join(vault, "fresh.md"), dir: vault, st: lstatSync(home) }, Buffer.from("x")), (err) => err.reason === REFUSAL.CHANGED, "checked as present, now absent"); noTemp(vault); if (!(process.getuid && process.getuid() === 0)) { const locked = join(vault, "Locked"); mkdirSync(locked); chmodSync(locked, 0o555); const c = loadToolsConfig({ roots: [{ name: "vault", path: vault, write: true }] }); assert.throws(() => writeFile(c, { root: "vault", path: "Locked/x.md", text: "x" }), (err) => err.reason === REFUSAL.UNWRITABLE); noTemp(locked); } });