diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 240bbaa9..080f06e5 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -195,6 +195,17 @@ jobs: - name: Test with coverage run: yarn workspace codingbuddy-claude-plugin test:coverage + plugin-validate-commands: + needs: install-dependencies + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup + + - name: Validate commands (collision guardrails) + run: yarn workspace codingbuddy-claude-plugin validate:commands + plugin-build: needs: install-dependencies runs-on: ubuntu-latest diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index ed5cbfd2..7104594c 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -44,6 +44,7 @@ "typecheck": "tsc --noEmit", "circular": "madge --circular --extensions ts src/", "format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.ts\"", + "validate:commands": "npx tsx scripts/validate-commands.ts", "test": "vitest run", "test:hooks": "python3 -m pytest tests/ -v --tb=short", "test:all": "vitest run && python3 -m pytest tests/ -v --tb=short", diff --git a/packages/claude-code-plugin/scripts/validate-commands.spec.ts b/packages/claude-code-plugin/scripts/validate-commands.spec.ts new file mode 100644 index 00000000..abaeda0b --- /dev/null +++ b/packages/claude-code-plugin/scripts/validate-commands.spec.ts @@ -0,0 +1,275 @@ +/** + * Tests for validate-commands script + * + * Verifies collision guardrails for reserved slash commands: + * 1. Reserved denylist includes known Claude Code commands + * 2. Command extraction from commands/ directory + * 3. Legacy allowlist prevents false positives + * 4. Forbidden bare commands trigger failure + * 5. Namespaced commands pass validation + * 6. Collision detection works correctly + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + RESERVED_COMMANDS, + LEGACY_ALLOWLIST, + extractCommandsFromDirectory, + getBaseCommandName, + isNamespaced, + isReservedCommand, + validateCommands, +} from './validate-commands'; + +// ============================================================================ +// Reserved Denylist +// ============================================================================ + +describe('reserved command denylist', () => { + it('includes known Claude Code built-in commands', () => { + const knownBuiltins = [ + 'help', + 'clear', + 'exit', + 'memory', + 'status', + 'doctor', + 'model', + 'vim', + 'review', + 'config', + 'init', + 'mcp', + 'login', + 'logout', + 'cost', + 'compact', + 'permissions', + 'listen', + 'bug', + 'terminal-setup', + ]; + + for (const cmd of knownBuiltins) { + expect(RESERVED_COMMANDS.has(cmd)).toBe(true); + } + }); + + it('is a non-empty set', () => { + expect(RESERVED_COMMANDS.size).toBeGreaterThan(20); + }); + + it('does not include plugin-specific commands', () => { + expect(RESERVED_COMMANDS.has('plan')).toBe(false); + expect(RESERVED_COMMANDS.has('act')).toBe(false); + expect(RESERVED_COMMANDS.has('eval')).toBe(false); + expect(RESERVED_COMMANDS.has('buddy')).toBe(false); + }); +}); + +// ============================================================================ +// Legacy Allowlist +// ============================================================================ + +describe('legacy allowlist', () => { + it('contains current bare commands', () => { + const expected = ['plan', 'act', 'eval', 'auto', 'buddy', 'checklist']; + for (const cmd of expected) { + expect(LEGACY_ALLOWLIST.has(cmd)).toBe(true); + } + }); + + it('does not overlap with reserved commands', () => { + for (const cmd of LEGACY_ALLOWLIST) { + expect(RESERVED_COMMANDS.has(cmd)).toBe(false); + } + }); +}); + +// ============================================================================ +// Command Extraction +// ============================================================================ + +describe('extractCommandsFromDirectory', () => { + const tmpDir = path.join(__dirname, '..', '__test_commands_tmp__'); + + beforeEach(() => { + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('extracts command names from .md files', () => { + fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, 'act.md'), '# Act'); + + const commands = extractCommandsFromDirectory(tmpDir); + expect(commands).toContain('plan'); + expect(commands).toContain('act'); + expect(commands).toHaveLength(2); + }); + + it('ignores non-.md files', () => { + fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'notes'); + fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}'); + + const commands = extractCommandsFromDirectory(tmpDir); + expect(commands).toEqual(['plan']); + }); + + it('returns empty array for non-existent directory', () => { + const commands = extractCommandsFromDirectory('/nonexistent/path'); + expect(commands).toEqual([]); + }); + + it('returns empty array for empty directory', () => { + const commands = extractCommandsFromDirectory(tmpDir); + expect(commands).toEqual([]); + }); + + it('detects the real commands/ directory', () => { + const realCommandsDir = path.resolve(__dirname, '..', 'commands'); + const commands = extractCommandsFromDirectory(realCommandsDir); + + expect(commands).toContain('plan'); + expect(commands).toContain('act'); + expect(commands).toContain('eval'); + expect(commands).toContain('auto'); + expect(commands).toContain('buddy'); + expect(commands).toContain('checklist'); + expect(commands.length).toBeGreaterThanOrEqual(6); + }); +}); + +// ============================================================================ +// Namespace Helpers +// ============================================================================ + +describe('getBaseCommandName', () => { + it('strips namespace prefix', () => { + expect(getBaseCommandName('codingbuddy:plan')).toBe('plan'); + }); + + it('returns bare name as-is', () => { + expect(getBaseCommandName('plan')).toBe('plan'); + }); + + it('handles nested colons', () => { + expect(getBaseCommandName('codingbuddy:sub:cmd')).toBe('sub:cmd'); + }); +}); + +describe('isNamespaced', () => { + it('returns true for namespaced commands', () => { + expect(isNamespaced('codingbuddy:plan')).toBe(true); + }); + + it('returns false for bare commands', () => { + expect(isNamespaced('plan')).toBe(false); + }); + + it('returns false for other namespaces', () => { + expect(isNamespaced('other:plan')).toBe(false); + }); +}); + +// ============================================================================ +// Collision Detection +// ============================================================================ + +describe('isReservedCommand', () => { + it('detects reserved bare commands', () => { + expect(isReservedCommand('help')).toBe(true); + expect(isReservedCommand('mcp')).toBe(true); + expect(isReservedCommand('config')).toBe(true); + }); + + it('detects reserved commands even with namespace', () => { + expect(isReservedCommand('codingbuddy:help')).toBe(true); + expect(isReservedCommand('codingbuddy:mcp')).toBe(true); + }); + + it('returns false for non-reserved commands', () => { + expect(isReservedCommand('plan')).toBe(false); + expect(isReservedCommand('buddy')).toBe(false); + expect(isReservedCommand('codingbuddy:plan')).toBe(false); + }); +}); + +// ============================================================================ +// Full Validation +// ============================================================================ + +describe('validateCommands', () => { + const tmpDir = path.join(__dirname, '..', '__test_validate_tmp__'); + + beforeEach(() => { + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('passes with current legacy commands', () => { + for (const cmd of LEGACY_ALLOWLIST) { + fs.writeFileSync(path.join(tmpDir, `${cmd}.md`), `# ${cmd}`); + } + + const result = validateCommands(tmpDir); + expect(result.valid).toBe(true); + expect(result.collisions).toHaveLength(0); + expect(result.namespaceViolations).toHaveLength(0); + }); + + it('fails when a reserved command is introduced', () => { + fs.writeFileSync(path.join(tmpDir, 'help.md'), '# Help'); + + const result = validateCommands(tmpDir); + expect(result.valid).toBe(false); + expect(result.collisions).toContain('help'); + }); + + it('fails when a bare command is not in legacy allowlist', () => { + fs.writeFileSync(path.join(tmpDir, 'my-new-command.md'), '# New'); + + const result = validateCommands(tmpDir); + expect(result.valid).toBe(false); + expect(result.namespaceViolations).toContain('my-new-command'); + }); + + it('reports both collision and namespace violation for reserved bare command', () => { + fs.writeFileSync(path.join(tmpDir, 'mcp.md'), '# MCP'); + + const result = validateCommands(tmpDir); + expect(result.valid).toBe(false); + expect(result.collisions).toContain('mcp'); + expect(result.namespaceViolations).toContain('mcp'); + }); + + it('handles empty commands directory', () => { + const result = validateCommands(tmpDir); + expect(result.valid).toBe(true); + expect(result.commands).toHaveLength(0); + }); + + it('handles non-existent directory', () => { + const result = validateCommands('/nonexistent/path'); + expect(result.valid).toBe(true); + expect(result.commands).toHaveLength(0); + }); + + it('validates the real commands/ directory passes', () => { + const realCommandsDir = path.resolve(__dirname, '..', 'commands'); + const result = validateCommands(realCommandsDir); + expect(result.valid).toBe(true); + expect(result.collisions).toHaveLength(0); + expect(result.namespaceViolations).toHaveLength(0); + }); +}); diff --git a/packages/claude-code-plugin/scripts/validate-commands.ts b/packages/claude-code-plugin/scripts/validate-commands.ts new file mode 100644 index 00000000..7b341689 --- /dev/null +++ b/packages/claude-code-plugin/scripts/validate-commands.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env ts-node +/** + * Validate Commands Script + * + * Collision guardrails for reserved slash commands. + * Detects when a plugin command name collides with Claude Code built-in commands. + * + * Checks: + * 1. Reserved command denylist (Claude Code built-ins) + * 2. Command extraction from commands/ directory + * 3. Collision detection against denylist + * 4. Namespace validation (codingbuddy:* convention) + * + * Usage: + * npx tsx scripts/validate-commands.ts + */ + +import * as path from 'path'; +import * as fs from 'fs'; + +// ============================================================================ +// Reserved Command Denylist +// ============================================================================ + +/** + * Claude Code built-in / reserved slash command names. + * Source: checked-in snapshot for deterministic, repo-local validation. + * + * Maintain this list when new Claude Code built-in commands are discovered. + */ +export const RESERVED_COMMANDS: ReadonlySet = new Set([ + // Core session commands + 'help', + 'clear', + 'exit', + 'quit', + 'compact', + 'reset', + + // Configuration & settings + 'config', + 'model', + 'permissions', + 'vim', + 'terminal-setup', + 'init', + 'settings', + + // Authentication + 'login', + 'logout', + + // Diagnostics & info + 'status', + 'doctor', + 'cost', + 'bug', + 'version', + + // Memory & context + 'memory', + 'context', + + // MCP & tools + 'mcp', + 'tools', + + // Code review & tasks + 'review', + + // Session management + 'listen', + 'resume', + 'continue', + + // IDE integration + 'ide', + + // Other known built-ins + 'add-dir', + 'release-notes', + 'pr-review', + 'fast', +]); + +// ============================================================================ +// Legacy Allowlist +// ============================================================================ + +/** + * Known legacy bare commands that are intentionally kept without namespace prefix. + * These existed before the codingbuddy:* namespace convention. + * New commands MUST use the codingbuddy:* namespace. + */ +export const LEGACY_ALLOWLIST: ReadonlySet = new Set([ + 'plan', + 'act', + 'eval', + 'auto', + 'buddy', + 'checklist', +]); + +// ============================================================================ +// Plugin Namespace +// ============================================================================ + +export const PLUGIN_NAMESPACE = 'codingbuddy'; +export const NAMESPACE_SEPARATOR = ':'; + +// ============================================================================ +// Validation Types +// ============================================================================ + +export interface ValidationResult { + valid: boolean; + commands: string[]; + collisions: string[]; + namespaceViolations: string[]; + errors: string[]; +} + +// ============================================================================ +// Command Extraction +// ============================================================================ + +/** + * Extracts command names from the commands/ directory. + * Each .md file in commands/ represents a slash command. + * Filename (without extension) is the command name. + */ +export function extractCommandsFromDirectory(commandsDir: string): string[] { + if (!fs.existsSync(commandsDir)) { + return []; + } + + return fs + .readdirSync(commandsDir) + .filter(file => file.endsWith('.md')) + .map(file => path.basename(file, '.md')); +} + +/** + * Extracts the base command name, stripping any namespace prefix. + * e.g., "codingbuddy:plan" → "plan", "plan" → "plan" + */ +export function getBaseCommandName(command: string): string { + const separatorIndex = command.indexOf(NAMESPACE_SEPARATOR); + if (separatorIndex === -1) { + return command; + } + return command.substring(separatorIndex + 1); +} + +/** + * Checks if a command uses the plugin namespace. + * e.g., "codingbuddy:plan" → true, "plan" → false + */ +export function isNamespaced(command: string): boolean { + return command.startsWith(`${PLUGIN_NAMESPACE}${NAMESPACE_SEPARATOR}`); +} + +// ============================================================================ +// Collision Detection +// ============================================================================ + +/** + * Checks a single command name against the reserved denylist. + * Returns true if the command collides with a reserved name. + */ +export function isReservedCommand(command: string): boolean { + const baseName = getBaseCommandName(command); + return RESERVED_COMMANDS.has(baseName); +} + +// ============================================================================ +// Main Validation +// ============================================================================ + +/** + * Validates all commands for collisions and namespace compliance. + * + * Rules: + * 1. No command may collide with a reserved Claude Code built-in + * 2. Bare (non-namespaced) commands must be in the legacy allowlist + * 3. New commands must use the codingbuddy:* namespace + */ +export function validateCommands(commandsDir: string): ValidationResult { + const result: ValidationResult = { + valid: true, + commands: [], + collisions: [], + namespaceViolations: [], + errors: [], + }; + + try { + const commands = extractCommandsFromDirectory(commandsDir); + result.commands = commands; + + for (const cmd of commands) { + // Check collision with reserved commands + if (isReservedCommand(cmd)) { + result.collisions.push(cmd); + result.valid = false; + } + + // Check namespace compliance: bare commands must be in legacy allowlist + if (!isNamespaced(cmd) && !LEGACY_ALLOWLIST.has(cmd)) { + result.namespaceViolations.push(cmd); + result.valid = false; + } + } + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + result.valid = false; + } + + return result; +} + +// ============================================================================ +// CLI Runner +// ============================================================================ + +function main(): void { + const pluginRoot = path.resolve(__dirname, '..'); + const commandsDir = path.join(pluginRoot, 'commands'); + + console.log('🔍 Validating plugin commands...\n'); + + const result = validateCommands(commandsDir); + + console.log(`Commands found: ${result.commands.join(', ') || '(none)'}`); + console.log(`Reserved denylist size: ${RESERVED_COMMANDS.size}`); + console.log(`Legacy allowlist: ${[...LEGACY_ALLOWLIST].join(', ')}`); + console.log(''); + + if (result.collisions.length > 0) { + console.error('❌ COLLISION DETECTED:'); + for (const cmd of result.collisions) { + console.error(` "${cmd}" collides with a reserved Claude Code command`); + } + console.error(''); + } + + if (result.namespaceViolations.length > 0) { + console.error('❌ NAMESPACE VIOLATION:'); + for (const cmd of result.namespaceViolations) { + console.error( + ` "${cmd}" is a bare command not in the legacy allowlist. Use "${PLUGIN_NAMESPACE}:${cmd}" instead.`, + ); + } + console.error(''); + } + + if (result.errors.length > 0) { + console.error('❌ ERRORS:'); + for (const err of result.errors) { + console.error(` ${err}`); + } + console.error(''); + } + + if (result.valid) { + console.log('✅ All commands passed validation.'); + } else { + console.error('❌ Command validation failed.'); + process.exit(1); + } +} + +// Only run CLI when executed directly (not imported) +if (require.main === module) { + main(); +}