diff --git a/src/router.ts b/src/router.ts index fa84b7d..4a0be8f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,7 +1,9 @@ +import * as os from "os"; import { Message, TextChannel } from "discord.js"; import { DisclawDatabase } from "./db/database"; import { DisclawConfig } from "./config/loader"; import { runAgent } from "./agent/runner"; +import { sanitizeForDiscord } from "./runtime/sanitize"; const DISCORD_MAX_LENGTH = 2000; @@ -110,7 +112,7 @@ export async function routeMessage( let firstMsgId: string | null = null; for (const chunk of chunks) { - const sent = await channel.send(chunk); + const sent = await channel.send(sanitizeForDiscord(chunk, { home: os.homedir() })); if (!firstMsgId) { firstMsgId = sent.id; } @@ -128,7 +130,8 @@ export async function routeMessage( } } catch (error) { const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; - await channel.send(`Fehler bei der Verarbeitung: ${msg}`).catch(() => {}); + const safeMsg = sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, { home: os.homedir() }); + await channel.send(safeMsg).catch(() => {}); } finally { clearInterval(typingInterval); } diff --git a/src/runtime/sanitize.ts b/src/runtime/sanitize.ts new file mode 100644 index 0000000..87e9e40 --- /dev/null +++ b/src/runtime/sanitize.ts @@ -0,0 +1,43 @@ +export interface SanitizeOptions { + repoRoot?: string; // e.g. /home/user/.disclaw + home?: string; // os.homedir() +} + +/** + * Sanitizes agent output before it is sent to a Discord channel. + * Replaces absolute paths and sensitive tokens so they are never leaked. + * + * Replacement order is intentional — longest/most-specific patterns first: + * 1. repoRoot → + * 2. home → ~ + * 3. Discord bot token pattern → + * 4. Anthropic API key pattern → + */ +export function sanitizeForDiscord( + text: string, + opts: SanitizeOptions = {} +): string { + let result = text; + + // 1. Replace repo root before home so that sub-paths are caught by the + // more specific substitution first (repoRoot is usually inside home). + if (opts.repoRoot) { + result = result.split(opts.repoRoot).join(""); + } + + // 2. Replace home directory with ~ + if (opts.home) { + result = result.split(opts.home).join("~"); + } + + // 3. Discord bot token — format: [MN]<23 base64url>.<6 base64url>.<27 base64url> + result = result.replace( + /[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g, + "" + ); + + // 4. Anthropic API key — format: sk-ant-<20+ base64url chars> + result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, ""); + + return result; +} diff --git a/tests/unit/sanitize-for-discord.test.ts b/tests/unit/sanitize-for-discord.test.ts new file mode 100644 index 0000000..1d704d3 --- /dev/null +++ b/tests/unit/sanitize-for-discord.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeForDiscord } from "../../src/runtime/sanitize"; + +describe("sanitizeForDiscord", () => { + // (a) Repo-root path is replaced with + it("replaces repoRoot with ", () => { + const text = "Reading config from /home/user/project/disclaw.yaml"; + const result = sanitizeForDiscord(text, { repoRoot: "/home/user/project" }); + expect(result).toBe("Reading config from /disclaw.yaml"); + expect(result).not.toContain("/home/user/project"); + }); + + // (b) Home directory is replaced with ~ + it("replaces home directory with ~", () => { + const text = "Workspace located at /home/user/.disclaw/workspaces/agent-x"; + const result = sanitizeForDiscord(text, { home: "/home/user" }); + expect(result).toBe("Workspace located at ~/.disclaw/workspaces/agent-x"); + expect(result).not.toContain("/home/user"); + }); + + // (c) Discord bot token pattern is redacted + it("redacts Discord bot tokens", () => { + const token = "MTA1NjYwNDczNTA3NDU2NjE2.GbXkRa.abcdefghijklmnopqrstuvwxyz123"; + const text = `Bot initialized with token ${token} — ready.`; + const result = sanitizeForDiscord(text); + expect(result).not.toContain(token); + expect(result).toContain(""); + }); + + // (d) Anthropic API key is redacted + it("redacts Anthropic API keys", () => { + const key = "sk-ant-api03-ABCDEFGHIJ1234567890abcdefghij"; + const text = `Using key ${key} for requests.`; + const result = sanitizeForDiscord(text); + expect(result).not.toContain(key); + expect(result).toContain(""); + }); + + // (e) Harmless text is not modified (no false positives) + it("does not modify harmless text", () => { + const text = "Hello! The agent processed your request successfully in 120ms."; + const result = sanitizeForDiscord(text, { home: "/home/user", repoRoot: "/home/user/project" }); + expect(result).toBe(text); + }); + + // (f) Empty string returns empty string + it("handles empty string input", () => { + expect(sanitizeForDiscord("")).toBe(""); + expect(sanitizeForDiscord("", { home: "/home/user", repoRoot: "/home/user/project" })).toBe(""); + }); + + // (g) No opts provided — plain text stays unchanged when no token patterns present + it("returns text unchanged when no opts and no token patterns", () => { + const text = "This is a perfectly normal response from the agent."; + expect(sanitizeForDiscord(text)).toBe(text); + }); + + // Ordering: repoRoot (sub-path of home) is replaced before home + it("replaces repoRoot before home so nested paths are handled correctly", () => { + const text = "/home/user/project/src/index.ts and /home/user/other.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "/home/user/project", + home: "/home/user", + }); + expect(result).toBe("/src/index.ts and ~/other.ts"); + }); +});