Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

### Fixed

- Refuse to save pending Discord messages through symbolic-link destinations.
- Protect legacy and source-backed user/server memory reads and writes from symbolic links, junctions, and path swaps.
- Require a text response for `!ask` commands even when their prompt is phrased without a question mark.
- Remove malformed `[REACT:]` directives from legacy Claude responses instead of sending them as Discord text.
Expand Down
13 changes: 11 additions & 2 deletions src/storage/pending.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from "fs";
import path from "path";
import { TextChannel, Message } from "discord.js";
import { PENDING_DIR } from "../config.js";
import { MESSAGES_DIR, PENDING_DIR } from "../config.js";
import { writeVerifiedUtf8File } from "./safeRead.js";

export function savePending(msg: Message) {
const filename = `${msg.id}.txt`;
Expand All @@ -13,7 +14,15 @@ export function savePending(msg: Message) {
`---`,
msg.content,
].join("\n");
fs.writeFileSync(path.join(PENDING_DIR, filename), content, "utf-8");
const saved = writeVerifiedUtf8File(
path.join(PENDING_DIR, filename),
content,
MESSAGES_DIR,
PENDING_DIR,
);
if (!saved) {
throw new Error("Could not safely save pending message");
}
}

export function removePending(msgId: string) {
Expand Down
44 changes: 44 additions & 0 deletions tests/pendingSafety.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

test("pending writes do not follow symbolic-link destinations", async (t) => {
const testDir = fs.mkdtempSync(
path.join(os.tmpdir(), "claudify-pending-symlink-"),
);
t.after(() => fs.rmSync(testDir, { recursive: true, force: true }));

const messagesDir = path.join(testDir, "messages");
process.env.MESSAGES_DIR = messagesDir;
const { savePending } = await import("../build/storage/pending.js");

const outsideFile = path.join(testDir, "outside.txt");
const pendingPath = path.join(
messagesDir,
"pending",
"222222222222222222.txt",
);
fs.writeFileSync(outsideFile, "must not be overwritten", "utf8");
try {
fs.symlinkSync(outsideFile, pendingPath);
} catch (error) {
if (error.code !== "EPERM" && error.code !== "EACCES") throw error;
t.skip("symbolic-link assertions require host permission");
return;
}

assert.throws(
() => savePending({
id: "222222222222222222",
author: { tag: "user#0001" },
channel: { name: "general" },
channelId: "111111111111111111",
createdAt: new Date("2026-08-31T12:30:00.000Z"),
content: "pending entry",
}),
/safely save pending message/u,
);
assert.equal(fs.readFileSync(outsideFile, "utf8"), "must not be overwritten");
});