From 5718284518e889fd9973da7876822f5b020941f6 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 17:49:44 +0200 Subject: [PATCH 1/7] feat(DIS-107): Zod schemas for disclaw.yaml, agent.yaml, CLI output Add DisclawConfigSchema and AgentYamlSchema with Zod validation. loader.ts and identity.ts validate external data at parse time. Clear error messages with field name and expected type. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 48 +++++++++++++--------- package.json | 3 +- src/agent/identity.ts | 35 +++++++++------- src/agent/schema.ts | 19 +++++++++ src/config/loader.ts | 38 +++++++++-------- src/config/schema.ts | 12 ++++++ tests/unit/schemas.test.ts | 84 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 src/agent/schema.ts create mode 100644 src/config/schema.ts create mode 100644 tests/unit/schemas.test.ts diff --git a/package-lock.json b/package-lock.json index 1b19c95..9c836aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "discord.js": "^14.16.0", "dotenv": "^16.4.0", "pino": "^10.3.1", - "yaml": "^2.6.0" + "yaml": "^2.6.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", @@ -1862,15 +1863,6 @@ "node": ">=12" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", @@ -1883,6 +1875,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2756,7 +2757,8 @@ "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true }, "node_modules/html-escaper": { "version": "2.0.2", @@ -2885,13 +2887,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2931,6 +2926,13 @@ "node": ">=8" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4343,7 +4345,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } - diff --git a/package.json b/package.json index efc41ff..37ddb1c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "discord.js": "^14.16.0", "dotenv": "^16.4.0", "pino": "^10.3.1", - "yaml": "^2.6.0" + "yaml": "^2.6.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/src/agent/identity.ts b/src/agent/identity.ts index c1997ec..89f057a 100644 --- a/src/agent/identity.ts +++ b/src/agent/identity.ts @@ -1,24 +1,16 @@ import * as fs from "fs"; import * as path from "path"; import * as YAML from "yaml"; +import { ZodError } from "zod"; +import { AgentYamlSchema } from "./schema"; + +export type { AgentYaml as AgentIdentity } from "./schema"; /** - * Metadata stored in agent.yaml -- DisClaw-specific fields only. - * Identity, personality, and instructions now live in CLAUDE.md, - * which Claude Code reads automatically. - */ -export interface AgentIdentity { - name: string; - display_name: string; - role: string; - channel_id: string; -} - -/** - * Loads agent.yaml from the workspace folder and returns the parsed identity. + * Loads agent.yaml from the workspace folder and returns the validated identity. * Re-read on every call so edits take effect immediately. */ -export function loadAgentIdentity(workspacePath: string): AgentIdentity { +export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml { const yamlPath = path.join(workspacePath, "agent.yaml"); if (!fs.existsSync(yamlPath)) { @@ -26,7 +18,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity { } const raw = fs.readFileSync(yamlPath, "utf-8"); - return YAML.parse(raw) as AgentIdentity; + const rawParsed = YAML.parse(raw); + + try { + return AgentYamlSchema.parse(rawParsed); + } catch (err) { + if (err instanceof ZodError) { + const first = err.errors[0]; + const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)"; + throw new Error( + `Invalid agent.yaml at ${yamlPath}: field "${fieldPath}" -- ${first.message}` + ); + } + throw err; + } } /** diff --git a/src/agent/schema.ts b/src/agent/schema.ts new file mode 100644 index 0000000..b1169c6 --- /dev/null +++ b/src/agent/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const AgentYamlSchema = z.object({ + name: z.string().min(1), + display_name: z.string().optional(), + role: z.string().optional(), + channel_id: z.string().min(1), +}); + +export type AgentYaml = z.infer; + +export const ClaudeJsonOutputSchema = z.union([ + z.object({ result: z.string() }), + z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })) }), + z.array(z.object({ type: z.string(), text: z.string().optional() })), + z.string(), +]); + +export type ClaudeJsonOutput = z.infer; diff --git a/src/config/loader.ts b/src/config/loader.ts index 00cb55b..b8dbf1f 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -3,19 +3,14 @@ import * as os from "os"; import * as path from "path"; import * as dotenv from "dotenv"; import * as YAML from "yaml"; +import { ZodError } from "zod"; import { childLogger } from "../runtime/logger"; +import { DisclawConfigSchema } from "./schema"; + +export type { DisclawConfig } from "./schema"; const log = childLogger("config"); -export interface DisclawConfig { - workspaces_root: string; - management_channel: string; - claude_command: string; - claude_timeout_seconds: number; - env_blocklist: string[]; - max_concurrent_agents: number; -} - export interface EnvConfig { DISCORD_BOT_TOKEN: string; DISCORD_GUILD_ID?: string; @@ -78,18 +73,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig { } const raw = fs.readFileSync(configPath, "utf-8"); - const parsed = YAML.parse(raw) as Partial; + const rawParsed = YAML.parse(raw); - // Apply defaults - const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces"; + let validated: DisclawConfig; + try { + validated = DisclawConfigSchema.parse(rawParsed); + } catch (err) { + if (err instanceof ZodError) { + const first = err.errors[0]; + const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)"; + throw new Error( + `Invalid disclaw.yaml: field "${fieldPath}" -- ${first.message}` + ); + } + throw err; + } const config: DisclawConfig = { - workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir), - management_channel: parsed.management_channel ?? "disclaw", - claude_command: parsed.claude_command ?? "claude", - claude_timeout_seconds: parsed.claude_timeout_seconds ?? 120, - env_blocklist: parsed.env_blocklist ?? [], - max_concurrent_agents: parsed.max_concurrent_agents ?? 4, + ...validated, + workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir), }; return config; diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..fcc67cb --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const DisclawConfigSchema = z.object({ + workspaces_root: z.string().min(1), + management_channel: z.string().min(1).default("disclaw"), + claude_command: z.string().min(1).default("claude"), + claude_timeout_seconds: z.number().int().positive().default(120), + max_concurrent_agents: z.number().int().positive().default(4), + env_blocklist: z.array(z.string()).default([]), +}); + +export type DisclawConfig = z.infer; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts new file mode 100644 index 0000000..5195629 --- /dev/null +++ b/tests/unit/schemas.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { ZodError } from "zod"; +import { DisclawConfigSchema } from "../../src/config/schema"; +import { AgentYamlSchema } from "../../src/agent/schema"; + +describe("DisclawConfigSchema", () => { + it("(a) parses valid disclaw.yaml data correctly", () => { + const input = { + workspaces_root: "/home/user/.disclaw/workspaces", + management_channel: "disclaw", + claude_command: "claude", + claude_timeout_seconds: 60, + max_concurrent_agents: 2, + env_blocklist: ["SECRET_KEY"], + }; + + const result = DisclawConfigSchema.parse(input); + + expect(result.workspaces_root).toBe("/home/user/.disclaw/workspaces"); + expect(result.management_channel).toBe("disclaw"); + expect(result.claude_command).toBe("claude"); + expect(result.claude_timeout_seconds).toBe(60); + expect(result.max_concurrent_agents).toBe(2); + expect(result.env_blocklist).toEqual(["SECRET_KEY"]); + }); + + it("(b) throws ZodError when workspaces_root is missing", () => { + const input = { + management_channel: "disclaw", + }; + + expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError); + }); + + it("(c) throws ZodError when claude_timeout_seconds is a string", () => { + const input = { + workspaces_root: "/some/path", + claude_timeout_seconds: "abc", + }; + + expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError); + }); + + it("(d) applies defaults when optional fields are not provided", () => { + const input = { + workspaces_root: "/some/path", + }; + + const result = DisclawConfigSchema.parse(input); + + expect(result.management_channel).toBe("disclaw"); + expect(result.claude_command).toBe("claude"); + expect(result.claude_timeout_seconds).toBe(120); + expect(result.max_concurrent_agents).toBe(4); + expect(result.env_blocklist).toEqual([]); + }); +}); + +describe("AgentYamlSchema", () => { + it("(e) parses valid agent.yaml data correctly", () => { + const input = { + name: "my-agent", + display_name: "My Agent", + role: "Backend developer", + channel_id: "1234567890", + }; + + const result = AgentYamlSchema.parse(input); + + expect(result.name).toBe("my-agent"); + expect(result.display_name).toBe("My Agent"); + expect(result.role).toBe("Backend developer"); + expect(result.channel_id).toBe("1234567890"); + }); + + it("(f) throws ZodError when channel_id is missing", () => { + const input = { + name: "my-agent", + role: "Backend developer", + }; + + expect(() => AgentYamlSchema.parse(input)).toThrow(ZodError); + }); +}); From 3dd75ddc30c84f959c0a336d6192941307bbfb75 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 17:50:14 +0200 Subject: [PATCH 2/7] refactor(DIS-108): extract DB schema to src/db/schema.sql (SSOT) Move all CREATE TABLE/INDEX DDL from database.ts to schema.sql. database.ts loads and executes schema.sql via db.exec(). Add integration test verifying idempotent schema init. Co-Authored-By: Claude Sonnet 4.6 --- src/db/database.ts | 26 +-------- src/db/schema.sql | 6 ++ tests/integration/db-init.test.ts | 97 +++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 tests/integration/db-init.test.ts diff --git a/src/db/database.ts b/src/db/database.ts index 37bb4c9..2b49ddb 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -2,29 +2,7 @@ import Database from "better-sqlite3"; import * as fs from "fs"; import * as path from "path"; -const SCHEMA_SQL = ` -CREATE TABLE IF NOT EXISTS workspaces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id TEXT NOT NULL UNIQUE, - guild_id TEXT NOT NULL, - agent_name TEXT NOT NULL UNIQUE, - workspace_path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER NOT NULL REFERENCES workspaces(id), - discord_msg_id TEXT NOT NULL UNIQUE, - role TEXT NOT NULL, - content TEXT NOT NULL, - author_name TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_conversations_workspace - ON conversations(workspace_id, created_at); -`; +const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8"); export interface Workspace { id: number; @@ -61,7 +39,7 @@ export class DisclawDatabase { } private runMigrations(): void { - this.db.exec(SCHEMA_SQL); + this.db.exec(schema); } // --- Workspace queries --- diff --git a/src/db/schema.sql b/src/db/schema.sql index 5e58204..4836d79 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,3 +1,7 @@ +-- DisClaw SQLite schema (SSOT) +-- All statements use IF NOT EXISTS for idempotent initialization. + +-- Workspaces: maps a Discord channel to a local agent workspace directory. CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL UNIQUE, @@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Conversations: stores per-message history for each workspace. CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id), @@ -17,5 +22,6 @@ CREATE TABLE IF NOT EXISTS conversations ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Index for efficient conversation history retrieval ordered by time. CREATE INDEX IF NOT EXISTS idx_conversations_workspace ON conversations(workspace_id, created_at); diff --git a/tests/integration/db-init.test.ts b/tests/integration/db-init.test.ts new file mode 100644 index 0000000..6a35b70 --- /dev/null +++ b/tests/integration/db-init.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DisclawDatabase } from "../../src/db/database"; + +/** + * Integration tests for DisclawDatabase schema initialization. + * + * Each test uses a fresh SQLite file in os.tmpdir() to ensure full isolation. + * The DB file is cleaned up after each test. + */ + +function makeTmpDbPath(): string { + return path.join(os.tmpdir(), `disclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe("db-init: schema initialization", () => { + let dbPath: string; + let db: DisclawDatabase; + + beforeEach(() => { + dbPath = makeTmpDbPath(); + }); + + afterEach(() => { + try { + db.close(); + } catch { + // ignore if already closed + } + try { + fs.unlinkSync(dbPath); + } catch { + // best-effort cleanup + } + }); + + // (a) All expected tables exist after initialization + it("creates all expected tables on first init", () => { + db = new DisclawDatabase(dbPath); + + // Query sqlite_master for user-created tables + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (b) Initializing the same DB path twice is idempotent (IF NOT EXISTS) + it("is idempotent when initialized twice (IF NOT EXISTS)", () => { + db = new DisclawDatabase(dbPath); + db.close(); + + // Second initialization must not throw + expect(() => { + db = new DisclawDatabase(dbPath); + }).not.toThrow(); + + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (c) Basic CRUD: create a workspace and retrieve it by channel_id + it("supports basic workspace CRUD", () => { + db = new DisclawDatabase(dbPath); + + const created = db.createWorkspace( + "channel-001", + "guild-001", + "test-agent", + "/tmp/test-agent" + ); + + expect(created.id).toBeGreaterThan(0); + expect(created.channel_id).toBe("channel-001"); + expect(created.guild_id).toBe("guild-001"); + expect(created.agent_name).toBe("test-agent"); + expect(created.workspace_path).toBe("/tmp/test-agent"); + expect(created.created_at).toBeTruthy(); + + const fetched = db.getWorkspaceByChannelId("channel-001"); + expect(fetched).toBeDefined(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.agent_name).toBe("test-agent"); + }); +}); From aeb4bd5b59af5984ea9ce340349e7a0c5e73b41e Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 18:01:11 +0200 Subject: [PATCH 3/7] fix(DIS-108): copy schema.sql to dist/ after tsc build TypeScript does not copy non-.ts files. Add a cpSync step to the build and dev scripts so dist/db/schema.sql exists at runtime. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index efc41ff..e7c9b25 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "Discord-bot-powered multi-agent workspace manager built on Claude Code", "main": "dist/index.js", "scripts": { - "build": "tsc", + "build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"", "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", + "dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", From 57cd3d4a431fd309bfb50f3bd050bbaf7674e7d2 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 17:48:24 +0200 Subject: [PATCH 4/7] 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"); + }); +}); From 90f96bdbf4c50c0dc45eeb7c4f97f18646143cf1 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 09:51:15 +0200 Subject: [PATCH 5/7] 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"; From 5869c15f9da74d0fcd38c57cccba829b3e4b96f9 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 09:58:34 +0200 Subject: [PATCH 6/7] 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> From 714e0c01e53da4f47fae3468a5890fb07967f444 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 10:02:17 +0200 Subject: [PATCH 7/7] 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"); + }); });