Compare commits

...

5 commits

Author SHA1 Message Date
dev
ce54b4c824 Merge pull request 'feat(DIS-102): sanitizeForDiscord strips paths and tokens before Discord send' (#46) from phase-1/sanitize-for-discord into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #46
2026-04-10 08:05:43 +00:00
Nick Tabeling
714e0c01e5 fix(DIS-102): sanitize response before DB storage to break path feedback loop
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
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 <noreply@anthropic.com>
2026-04-10 10:02:17 +02:00
Nick Tabeling
5869c15f9d fix(DIS-102): handle both slash styles for Windows path sanitization
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
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 <noreply@anthropic.com>
2026-04-10 09:58:34 +02:00
Nick Tabeling
90f96bdbf4 fix: add local type imports for AgentIdentity and DisclawConfig
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
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 <noreply@anthropic.com>
2026-04-10 09:51:15 +02:00
Nick Tabeling
57cd3d4a43 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 <noreply@anthropic.com>
2026-04-10 09:50:37 +02:00
5 changed files with 167 additions and 4 deletions

View file

@ -3,6 +3,7 @@ import * as path from "path";
import * as YAML from "yaml"; import * as YAML from "yaml";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { AgentYamlSchema } from "./schema"; import { AgentYamlSchema } from "./schema";
import type { AgentYaml as AgentIdentity } from "./schema";
export type { AgentYaml as AgentIdentity } from "./schema"; export type { AgentYaml as AgentIdentity } from "./schema";

View file

@ -6,6 +6,7 @@ import * as YAML from "yaml";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { childLogger } from "../runtime/logger"; import { childLogger } from "../runtime/logger";
import { DisclawConfigSchema } from "./schema"; import { DisclawConfigSchema } from "./schema";
import type { DisclawConfig } from "./schema";
export type { DisclawConfig } from "./schema"; export type { DisclawConfig } from "./schema";

View file

@ -1,7 +1,9 @@
import * as os from "os";
import { Message, TextChannel } from "discord.js"; import { Message, TextChannel } from "discord.js";
import { DisclawDatabase } from "./db/database"; import { DisclawDatabase } from "./db/database";
import { DisclawConfig } from "./config/loader"; import { DisclawConfig } from "./config/loader";
import { runAgent } from "./agent/runner"; import { runAgent } from "./agent/runner";
import { sanitizeForDiscord } from "./runtime/sanitize";
const DISCORD_MAX_LENGTH = 2000; const DISCORD_MAX_LENGTH = 2000;
@ -105,8 +107,18 @@ export async function routeMessage(
message.author.username 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 // Split and send response
const chunks = splitMessage(response); const chunks = splitMessage(sanitizedResponse);
let firstMsgId: string | null = null; let firstMsgId: string | null = null;
for (const chunk of chunks) { 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) { if (firstMsgId) {
db.addConversation( db.addConversation(
workspace.id, workspace.id,
firstMsgId, firstMsgId,
"assistant", "assistant",
response, sanitizedResponse,
null null
); );
} }
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; 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 { } finally {
clearInterval(typingInterval); clearInterval(typingInterval);
} }

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

@ -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 <disclaw>
* 2. home ~
* 3. Discord bot token pattern <redacted>
* 4. Anthropic API key pattern <redacted>
*/
/** 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, "<disclaw>");
}
}
// 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,
"<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,87 @@
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");
});
// 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 <disclaw>\\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 <disclaw>/src/main.ts");
});
});