Files
stack/packages/discord/tests/tools.test.mjs
T
jason.woltjeandClaude Opus 5 1ac812d3d5 feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)
A binding may declare `tools` with named roots. pi starts with
--no-builtin-tools and the package's own extension, allowlisting
list_dir, read_file and search. src/tools.mjs holds the rules: names
not paths, per-segment lstat walk, one checked descriptor read that
refuses symlinks, swaps, FIFOs, hard links and oversize files, credential
shapes refusing the whole read, and a per-message call budget. The engine
settles on agent_end and records tool calls in the turn record.

Jason's rulings R1-R7 in the brief, section 7. rev-code-02 approved
round 2 (comment 26276) on tree 43f0329b after four round 1 fixes.
Suite 48/48, node tests 116. Not pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 19:52:21 -05:00

225 lines
13 KiB
JavaScript

// The read-only 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, symlinkSync, chmodSync, linkSync, lstatSync, renameSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { loadToolsConfig, createToolSet, listDir, readFile, search, looksLikeCredential, readVerified, REFUSAL, 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.deepEqual(TOOL_NAMES, ["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);
});