diff --git a/src/commands/new-agent.ts b/src/commands/new-agent.ts index 7cc55b2..f71a7a3 100644 --- a/src/commands/new-agent.ts +++ b/src/commands/new-agent.ts @@ -12,6 +12,21 @@ import { setupAgentWorkspace } from "../agent/identity"; // Allowed characters for agent names: lowercase letters, numbers, hyphens const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/; +/** + * Validates that an agent name is safe and does not escape the workspaces root + * via path traversal. Throws an Error if the resolved path would lie outside root. + */ +export function validateAgentName(name: string, workspacesRoot: string): void { + if (!name) { + throw new Error(`Ungültiger Agent-Name: "${name}"`); + } + const root = path.resolve(workspacesRoot); + const wsPath = path.resolve(root, name); + if (!wsPath.startsWith(root + path.sep)) { + throw new Error(`Ungültiger Agent-Name: "${name}"`); + } +} + export const newAgentCommand = new SlashCommandBuilder() .setName("new-agent") .setDescription("Erstellt einen neuen Agenten mit eigenem Kanal und Workspace") @@ -48,6 +63,15 @@ export async function handleNewAgent( return; } + // Path-traversal containment check + try { + validateAgentName(name, config.workspaces_root); + } catch (error) { + const msg = error instanceof Error ? error.message : "Ungültiger Agent-Name"; + await interaction.reply({ content: msg, ephemeral: true }); + return; + } + // Check uniqueness if (db.getWorkspaceByName(name)) { await interaction.reply({ diff --git a/tests/unit/path-traversal.test.ts b/tests/unit/path-traversal.test.ts new file mode 100644 index 0000000..6bfc51d --- /dev/null +++ b/tests/unit/path-traversal.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { validateAgentName } from "../../src/commands/new-agent"; + +const ROOT = "/tmp/ws"; + +describe("validateAgentName – path traversal containment", () => { + it('throws for "../etc"', () => { + expect(() => validateAgentName("../etc", ROOT)).toThrow(); + }); + + it('throws for ".."', () => { + expect(() => validateAgentName("..", ROOT)).toThrow(); + }); + + it('does not throw for "a/../b" (resolves to /tmp/ws/b, stays inside root)', () => { + // path.resolve("/tmp/ws", "a/../b") => "/tmp/ws/b" -- still inside root + expect(() => validateAgentName("a/../b", ROOT)).not.toThrow(); + }); + + it('throws for empty string ""', () => { + expect(() => validateAgentName("", ROOT)).toThrow(); + }); + + it('allows "my-agent"', () => { + expect(() => validateAgentName("my-agent", ROOT)).not.toThrow(); + }); + + it('allows "agent-123"', () => { + expect(() => validateAgentName("agent-123", ROOT)).not.toThrow(); + }); +});