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

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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-18 07:27:50 -05:00
co-authored by Claude Fable 5.1
parent 1ac812d3d5
commit 1685deb423
24 changed files with 1519 additions and 113 deletions
+131 -3
View File
@@ -1,12 +1,12 @@
// The read-only tools' confinement, tested without pi. Every row here is a
// 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, symlinkSync, chmodSync, linkSync, lstatSync, renameSync } from "node:fs";
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, listDir, readFile, search, looksLikeCredential, readVerified, REFUSAL, TOOL_NAMES, LIST_MAX_ENTRIES, SEARCH_MAX_HITS } from "../src/tools.mjs";
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
@@ -141,7 +141,10 @@ test("tools: the tool set renders text for the model, records details for the jo
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", () => {
@@ -222,3 +225,128 @@ test("tools: an unreadable file under the root is skipped by search and refused
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);
}
});