/** * Pure utility functions for plan mode. * Extracted for testability. */ // Destructive commands blocked in plan mode const DESTRUCTIVE_PATTERNS = [ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i, /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i, /(^|[^<])>(?!>)/, />>/, /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, /\byarn\s+(add|remove|install|publish)/i, /\bpnpm\s+(add|remove|install|publish)/i, /\bpip\s+(install|uninstall)/i, /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, /\bbrew\s+(install|uninstall|upgrade)/i, /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, /\breboot\b/i, /\bshutdown\b/i, /\bsystemctl\s+(start|stop|restart|enable|disable)/i, /\bservice\s+\S+\s+(start|stop|restart)/i, /\b(vim?|nano|emacs|code|subl)\b/i, ]; // Safe read-only commands allowed in plan mode const SAFE_PATTERNS = [ /^\s*cd\b/, /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/, /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, /^\s*git\s+ls-/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, /^\s*yarn\s+(list|info|why|audit)/i, /^\s*node\s+--version/i, /^\s*python\s+--version/i, /^\s*curl\s/i, /^\s*wget\s+-O\s*-/i, /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/, /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*fzf\b/, /^\s*exa\b/, ]; // Redirections that are safe in read-only huddle mode (suppress output only) const SAFE_REDIRECTION_PATTERNS = [ /\b\d*>\s*\/dev\/null\b/g, /\b\d*>>\s*\/dev\/null\b/g, /\b\d*>\s*&\d+\b/g, /\b\d*>>\s*&\d+\b/g, ]; function stripSafeRedirections(command: string): string { let sanitized = command; for (const pattern of SAFE_REDIRECTION_PATTERNS) { sanitized = sanitized.replace(pattern, ""); } return sanitized; } /** * Split command into parts respecting quoted strings. * Handles: &&, ;, | (but not inside quotes) */ function splitCommandRespectingQuotes(command: string): string[] { const parts: string[] = []; let current = ""; let inSingleQuote = false; let inDoubleQuote = false; let i = 0; while (i < command.length) { const char = command[i]; const nextChar = command[i + 1]; if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; current += char; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; current += char; } else if (!inSingleQuote && !inDoubleQuote) { // Check for separators outside quotes if (char === "&" && nextChar === "&") { parts.push(current.trim()); current = ""; i += 2; // Skip both & continue; } else if (char === ";") { parts.push(current.trim()); current = ""; } else if (char === "|") { parts.push(current.trim()); current = ""; } else { current += char; } } else { current += char; } i++; } if (current.trim()) { parts.push(current.trim()); } return parts; } export function isSafeCommand(command: string): boolean { // Allow benign output-suppression redirections before destructive checks. const commandForDestructiveCheck = stripSafeRedirections(command); // Check for destructive patterns anywhere in the command const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(commandForDestructiveCheck)); if (isDestructive) return false; // Split compound commands and check each part const parts = splitCommandRespectingQuotes(command); for (const part of parts) { const trimmed = part.trim(); if (!trimmed) continue; // Check if this part starts with a safe command const isPartSafe = SAFE_PATTERNS.some((p) => p.test(trimmed)); if (!isPartSafe) return false; } return true; }