From 57cd3d4a431fd309bfb50f3bd050bbaf7674e7d2 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 17:48:24 +0200 Subject: [PATCH 1/4] feat(DIS-102): sanitizeForDiscord strips paths and tokens before Discord send Add sanitizeForDiscord() in src/runtime/sanitize.ts. Router applies sanitizer to all channel.send() calls. Prevents path/token leaks into Discord channels. Co-Authored-By: Claude Sonnet 4.6 --- src/router.ts | 7 ++- src/runtime/sanitize.ts | 43 ++++++++++++++++ tests/unit/sanitize-for-discord.test.ts | 67 +++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/runtime/sanitize.ts create mode 100644 tests/unit/sanitize-for-discord.test.ts 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"); + }); +}); -- 2.45.2 From 90f96bdbf4c50c0dc45eeb7c4f97f18646143cf1 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 09:51:15 +0200 Subject: [PATCH 2/4] fix: add local type imports for AgentIdentity and DisclawConfig Re-exported types via 'export type { X } from' are not available as local names in the same file. Add explicit 'import type' so TypeScript resolves the types used in function signatures. Co-Authored-By: Claude Sonnet 4.6 --- src/agent/identity.ts | 1 + src/config/loader.ts | 1 + 2 files changed, 2 insertions(+) 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"; -- 2.45.2 From 5869c15f9da74d0fcd38c57cccba829b3e4b96f9 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 09:58:34 +0200 Subject: [PATCH 3/4] fix(DIS-102): handle both slash styles for Windows path sanitization os.homedir() returns backslashes on Windows but Claude Code outputs paths with forward slashes. Replace both C:\Users\x and C:/Users/x variants so home and repoRoot are always redacted. Co-Authored-By: Claude Sonnet 4.6 --- src/runtime/sanitize.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/runtime/sanitize.ts b/src/runtime/sanitize.ts index 87e9e40..bc7431d 100644 --- a/src/runtime/sanitize.ts +++ b/src/runtime/sanitize.ts @@ -13,6 +13,17 @@ export interface SanitizeOptions { * 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 = {} @@ -22,12 +33,16 @@ export function sanitizeForDiscord( // 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(""); + for (const variant of pathVariants(opts.repoRoot)) { + result = replaceAll(result, variant, ""); + } } - // 2. Replace home directory with ~ + // 2. Replace home directory with ~ (both slash styles for Windows) if (opts.home) { - result = result.split(opts.home).join("~"); + for (const variant of pathVariants(opts.home)) { + result = replaceAll(result, variant, "~"); + } } // 3. Discord bot token — format: [MN]<23 base64url>.<6 base64url>.<27 base64url> -- 2.45.2 From 714e0c01e53da4f47fae3468a5890fb07967f444 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 10:02:17 +0200 Subject: [PATCH 4/4] fix(DIS-102): sanitize response before DB storage to break path feedback loop The root cause was twofold: 1. Raw (unsanitized) agent responses were stored in the conversation history DB. On subsequent calls, these paths were fed back to the agent as context, causing it to reproduce full paths regardless of Discord-side sanitization. 2. repoRoot (workspace_path) was not passed to sanitizeForDiscord, so workspace-specific paths were only partially masked via the home directory fallback. Fix: sanitize the full response once before both sending to Discord and storing in the DB. Pass workspace_path as repoRoot for precise path replacement. Add Windows path tests. Co-Authored-By: Claude Sonnet 4.6 --- src/router.ts | 23 ++++++++++++++++++----- tests/unit/sanitize-for-discord.test.ts | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/router.ts b/src/router.ts index 4a0be8f..cd5057a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -107,30 +107,43 @@ 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) { - const sent = await channel.send(sanitizeForDiscord(chunk, { home: os.homedir() })); + const sent = await channel.send(chunk); if (!firstMsgId) { firstMsgId = sent.id; } } - // 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"; - const safeMsg = sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, { home: os.homedir() }); + 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/tests/unit/sanitize-for-discord.test.ts b/tests/unit/sanitize-for-discord.test.ts index 1d704d3..5a75e42 100644 --- a/tests/unit/sanitize-for-discord.test.ts +++ b/tests/unit/sanitize-for-discord.test.ts @@ -64,4 +64,24 @@ describe("sanitizeForDiscord", () => { }); 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"); + }); }); -- 2.45.2