feat(DIS-102): sanitizeForDiscord strips paths and tokens before Discord send
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Nick Tabeling 2026-04-09 17:48:24 +02:00
parent 6039e992bd
commit 7ddc22dc0f
3 changed files with 115 additions and 2 deletions

View file

@ -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);
}

43
src/runtime/sanitize.ts Normal file
View file

@ -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 <disclaw>
* 2. home ~
* 3. Discord bot token pattern <redacted>
* 4. Anthropic API key pattern <redacted>
*/
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("<disclaw>");
}
// 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,
"<redacted>"
);
// 4. Anthropic API key — format: sk-ant-<20+ base64url chars>
result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, "<redacted>");
return result;
}

View file

@ -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 <disclaw>
it("replaces repoRoot with <disclaw>", () => {
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>/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("<redacted>");
});
// (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("<redacted>");
});
// (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("<disclaw>/src/index.ts and ~/other.ts");
});
});