Compare commits
5 commits
67433d63fa
...
ce54b4c824
| Author | SHA1 | Date | |
|---|---|---|---|
| ce54b4c824 | |||
|
|
714e0c01e5 | ||
|
|
5869c15f9d | ||
|
|
90f96bdbf4 | ||
|
|
57cd3d4a43 |
5 changed files with 167 additions and 4 deletions
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
58
src/runtime/sanitize.ts
Normal 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;
|
||||||
|
}
|
||||||
87
tests/unit/sanitize-for-discord.test.ts
Normal file
87
tests/unit/sanitize-for-discord.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue