From b4cc00e93c7b499cc4183ede557933d4327dba14 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 11:19:28 +0200 Subject: [PATCH] fix(new-agent): add path traversal containment check Extracts validateAgentName() with path.resolve containment guard after regex validation; returns clean ephemeral Discord reply on violation. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/new-agent.ts | 24 ++++++++++++++++++++++++ tests/unit/path-traversal.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/unit/path-traversal.test.ts 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(); + }); +});