diff --git a/src/agent/identity.ts b/src/agent/identity.ts index 89f057a..3d99b26 100644 --- a/src/agent/identity.ts +++ b/src/agent/identity.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as YAML from "yaml"; import { ZodError } from "zod"; import { AgentYamlSchema } from "./schema"; +import type { AgentYaml as AgentIdentity } from "./schema"; export type { AgentYaml as AgentIdentity } from "./schema"; diff --git a/src/config/loader.ts b/src/config/loader.ts index b8dbf1f..b26a7f3 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -6,6 +6,7 @@ import * as YAML from "yaml"; import { ZodError } from "zod"; import { childLogger } from "../runtime/logger"; import { DisclawConfigSchema } from "./schema"; +import type { DisclawConfig } from "./schema"; export type { DisclawConfig } from "./schema"; diff --git a/src/router.ts b/src/router.ts index fa84b7d..cd5057a 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; @@ -105,8 +107,18 @@ export async function routeMessage( message.author.username ); + // Sanitize the full response once, then use the sanitized version + // for both Discord output and conversation history storage. + // This prevents a feedback loop where raw paths in stored history + // get fed back to the agent as context, causing it to reproduce them. + const sanitizeOpts = { + repoRoot: workspace.workspace_path, + home: os.homedir(), + }; + const sanitizedResponse = sanitizeForDiscord(response, sanitizeOpts); + // Split and send response - const chunks = splitMessage(response); + const chunks = splitMessage(sanitizedResponse); let firstMsgId: string | null = null; for (const chunk of chunks) { @@ -116,19 +128,23 @@ export async function routeMessage( } } - // Store assistant response (use first message id for dedup) + // Store sanitized assistant response (use first message id for dedup) if (firstMsgId) { db.addConversation( workspace.id, firstMsgId, "assistant", - response, + sanitizedResponse, null ); } } 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}`, { + repoRoot: workspace.workspace_path, + 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..bc7431d --- /dev/null +++ b/src/runtime/sanitize.ts @@ -0,0 +1,58 @@ +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 → + */ +/** Returns both backslash and forward-slash variants of a path (for Windows). */ +function pathVariants(p: string): string[] { + const fwd = p.replace(/\\/g, "/"); + const bwd = p.replace(/\//g, "\\"); + return fwd === bwd ? [p] : [p, fwd, bwd]; +} + +function replaceAll(text: string, search: string, replacement: string): string { + return text.split(search).join(replacement); +} + +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) { + for (const variant of pathVariants(opts.repoRoot)) { + result = replaceAll(result, variant, ""); + } + } + + // 2. Replace home directory with ~ (both slash styles for Windows) + if (opts.home) { + for (const variant of pathVariants(opts.home)) { + result = replaceAll(result, variant, "~"); + } + } + + // 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..5a75e42 --- /dev/null +++ b/tests/unit/sanitize-for-discord.test.ts @@ -0,0 +1,87 @@ +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"); + }); + + // Windows-style backslash paths are sanitized + it("replaces Windows backslash paths", () => { + const text = "File at C:\\Users\\dev\\.disclaw\\workspaces\\agent-x\\src\\main.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x", + home: "C:\\Users\\dev", + }); + expect(result).toBe("File at \\src\\main.ts"); + }); + + // Mixed slash styles (common on Windows when tools output forward slashes) + it("replaces forward-slash variants of Windows paths", () => { + const text = "File at C:/Users/dev/.disclaw/workspaces/agent-x/src/main.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x", + home: "C:\\Users\\dev", + }); + expect(result).toBe("File at /src/main.ts"); + }); +});