diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index aa66c95bdd..cf57f33449 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -16,6 +16,7 @@ export const CLI_TOOLS = { }, modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"], settingsFile: "~/.claude/settings.json", + defaultCommand: "claude", defaultModels: [ { id: "opus", @@ -47,6 +48,7 @@ export const CLI_TOOLS = { color: "#10A37F", description: "OpenAI Codex CLI", configType: "custom", + defaultCommand: "codex", }, droid: { id: "droid", @@ -55,6 +57,7 @@ export const CLI_TOOLS = { color: "#00D4FF", description: "Factory Droid AI Assistant", configType: "custom", + defaultCommand: "droid", }, openclaw: { id: "openclaw", @@ -63,6 +66,7 @@ export const CLI_TOOLS = { color: "#FF6B35", description: "Open Claw AI Assistant", configType: "custom", + defaultCommand: "openclaw", }, cursor: { id: "cursor", @@ -72,6 +76,7 @@ export const CLI_TOOLS = { description: "Cursor AI Code Editor", configType: "guide", requiresCloud: true, + defaultCommands: ["agent", "cursor"], notes: [ { type: "warning", text: "Requires Cursor Pro account to use this feature." }, { @@ -95,6 +100,7 @@ export const CLI_TOOLS = { color: "#00D1B2", description: "Cline AI Coding Assistant CLI", configType: "custom", + defaultCommand: "cline", }, kilo: { id: "kilo", @@ -103,6 +109,7 @@ export const CLI_TOOLS = { color: "#FF6B6B", description: "Kilo Code AI Assistant CLI", configType: "custom", + defaultCommand: "kilocode", }, continue: { id: "continue", @@ -180,6 +187,7 @@ export const CLI_TOOLS = { color: "#FF6B35", description: "OpenCode AI coding agent (Terminal)", configType: "guide", + defaultCommand: "opencode", notes: [ { type: "warning", diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index b8ef8a90c9..56055a49d8 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -197,15 +197,220 @@ const getRuntimeMode = () => { return VALID_RUNTIME_MODES.has(mode) ? mode : "auto"; }; +/** + * T12: Validate a CLI executable path to prevent shell injection. + * Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file. + * Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026). + */ +const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"]; + +/** + * Check if a path is within a parent directory (case-insensitive, handles mixed separators). + * Normalizes both paths to forward slashes before comparison to handle + * inconsistent separator styles on Windows. + */ +const isPathWithin = (childPath: string, parentPath: string): boolean => { + // Normalize to forward slashes for consistent comparison + const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/"); + const normalizedChild = normalize(childPath); + const normalizedParent = normalize(parentPath); + + if (normalizedChild === normalizedParent) return true; + + // Ensure parent ends with / for proper prefix matching + const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/"; + + return normalizedChild.startsWith(parentWithSep); +}; + +/** + * Pre-compute expected parent directories at module startup for performance. + * These are the allowed directories for CLI binary installation locations. + */ +const getExpectedParentPaths = (): string[] => { + const home = os.homedir(); + const userProfile = process.env.USERPROFILE || home; + + const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]); + const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [ + path.join(home, "AppData", "Local"), + path.join(userProfile, "AppData", "Local"), + userProfile, + ]); + const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [ + "C:\\Program Files", + "C:\\Program Files (x86)", + ]); + const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [ + "C:\\Program Files", + "C:\\Program Files (x86)", + ]); + + return [ + home, + userProfile, + validatedAppData, + validatedLocalAppData, + validatedProgramFiles, + validatedProgramFilesX86, + ].filter(Boolean); +}; + +// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call) +const EXPECTED_PARENT_PATHS = getExpectedParentPaths(); + +const isSafePath = (execPath: string): boolean => { + if (!execPath || !path.isAbsolute(execPath)) return false; + if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false; + // Allow path.sep and path.delimiter — no further character filtering needed + return true; +}; + +/** + * Validate that an environment variable value is a safe, absolute path + * within acceptable directory trees. Rejects traversal, special chars, + * and paths outside expected locations. + */ +const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => { + if (!value) return ""; + const trimmed = value.trim(); + + // Reject if not absolute + if (!path.isAbsolute(trimmed)) return ""; + + // Reject dangerous characters (same as isSafePath but applied to env vars) + if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return ""; + + // Reject if contains path traversal segments + const normalized = path.normalize(trimmed); + if (normalized.includes("..")) return ""; + + // Reject if outside allowed parent directories + if (allowedParents.length > 0) { + const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent)); + if (!withinAllowed) return ""; + } + + return normalized; +}; + const getExtraPaths = () => String(process.env.CLI_EXTRA_PATHS || "") .split(path.delimiter) .map((segment) => segment.trim()) - .filter(Boolean); + .filter(Boolean) + .filter((p) => { + // Must be absolute + if (!path.isAbsolute(p)) return false; + // No dangerous characters + if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false; + // No path traversal + if (path.normalize(p).includes("..")) return false; + return true; + }); + +/** + * Get known installation paths for a specific CLI tool on Windows. + * Returns ONLY verified, tool-specific paths - NOT generic user bin directories. + * This is more secure than searching PATH as it checks known locations only. + */ +const getKnownToolPaths = (toolId: string): string[] => { + if (!isWindows()) return []; + + const home = os.homedir(); + const userProfile = process.env.USERPROFILE || home; + + // Validate environment paths against allowed parent directories + const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]); + const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [ + path.join(home, "AppData", "Local"), + path.join(userProfile, "AppData", "Local"), + userProfile, + ]); + + // Cache nvm node path to avoid duplicate detection calls + const nvmNodePath = getNvmNodePath(); + + // Tool-specific known installation paths (verified locations only) + const knownPaths: Record = { + claude: [ + // Official Claude Code standalone installer locations + path.join(home, ".local", "bin", "claude.exe"), + ...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []), + ...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []), + ], + codex: [ + path.join(home, ".local", "bin", "codex"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "codex.cmd")] : []), + ], + droid: [ + path.join(home, ".local", "bin", "droid"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "droid.cmd")] : []), + ], + openclaw: [ + path.join(home, ".local", "bin", "openclaw"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []), + ], + cursor: [ + path.join(home, ".local", "bin", "agent"), + path.join(home, ".local", "bin", "cursor"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []), + ...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "agent.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []), + ], + cline: [ + path.join(home, ".local", "bin", "cline"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "cline.cmd")] : []), + ], + kilo: [ + path.join(home, ".local", "bin", "kilocode"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []), + ], + opencode: [ + path.join(home, ".local", "bin", "opencode"), + // npm global (only if nvm-windows is detected) + ...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []), + ...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []), + ], + // Add other tools as needed with their specific known paths + }; + + return knownPaths[toolId] || []; +}; + +/** + * Detect nvm-windows installation path dynamically from current Node.js executable. + * Returns the directory containing node.exe if nvm is detected, null otherwise. + */ +const getNvmNodePath = (): string | null => { + // Simple heuristic: if process.execPath includes "nvm", use its directory + if (process.execPath.toLowerCase().includes("nvm")) { + return path.dirname(process.execPath); + } + + return null; +}; const getLookupEnv = () => { const env = { ...process.env }; const extraPaths = getExtraPaths(); + + // Only add user-specified extra paths, NOT generic user directories + // This is more secure - user explicitly opts in via CLI_EXTRA_PATHS if (extraPaths.length > 0) { env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter); } @@ -223,20 +428,6 @@ const resolveToolCommands = (toolId: string): string[] => { return tool.defaultCommand ? [tool.defaultCommand] : []; }; -/** - * T12: Validate a CLI executable path to prevent shell injection. - * Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file. - * Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026). - */ -const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"]; - -const isSafePath = (execPath: string): boolean => { - if (!execPath || !path.isAbsolute(execPath)) return false; - if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false; - // Allow path.sep and path.delimiter — no further character filtering needed - return true; -}; - const checkExplicitPath = async (commandPath: string) => { // Reject paths that look like injection attempts if (!isSafePath(commandPath)) { @@ -294,14 +485,93 @@ const locateCommand = async (command: string, env: Record { + if (!path.isAbsolute(commandPath)) { + return { installed: false, commandPath: null, reason: "not_absolute" }; + } + + if (!isSafePath(commandPath)) { + return { installed: false, commandPath: null, reason: "unsafe_path" }; + } + + try { + // Resolve symlinks to get the real path and detect symlink escapes + const realPath = await fs.realpath(commandPath); + + // Verify the resolved path is still within expected directories + // Use pre-computed expected parent paths (cached at module startup for performance) + const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent)); + + if (!isWithinExpected) { + return { installed: false, commandPath: null, reason: "symlink_escape" }; + } + + // Verify it's a regular file with reasonable size + const stat = await fs.stat(realPath); + if (!stat.isFile()) { + return { installed: false, commandPath: null, reason: "not_file" }; + } + + // CLI binaries should be > 1KB and < 100MB + // This catches suspicious files while allowing for wrapper scripts + if (stat.size < 1024 || stat.size > 100 * 1024 * 1024) { + return { installed: false, commandPath: null, reason: "suspicious_size" }; + } + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException).code; + if (errorCode === "ENOENT") { + return { installed: false, commandPath: null, reason: "not_found" }; + } + if (errorCode === "EINVAL") { + return { installed: false, commandPath: null, reason: "invalid_path" }; + } + return { installed: false, commandPath: null, reason: "access_error" }; + } + + try { + await fs.access(commandPath, fs.constants.X_OK); + return { installed: true, commandPath, reason: null }; + } catch { + return { installed: true, commandPath, reason: "not_executable" }; + } +}; + const locateCommandCandidate = async ( commands: string[], - env: Record + env: Record, + toolId?: string ) => { if (!Array.isArray(commands) || commands.length === 0) { return { command: null, installed: false, commandPath: null, reason: "missing_command" }; } + // SECURITY: First check known installation paths for this specific tool + // This avoids searching PATH and reduces attack surface + if (toolId && isWindows()) { + const knownPaths = getKnownToolPaths(toolId); + for (const knownPath of knownPaths) { + const result = await checkKnownPath(knownPath); + if (result.installed && result.reason === null) { + return { + command: commands[0], + installed: true, + commandPath: result.commandPath, + reason: null, + }; + } + } + } + + // Fallback: search PATH (user can set CLI_EXTRA_PATHS if needed) for (const command of commands) { const located = await locateCommand(command, env); if (located.installed || located.reason !== "not_found") { @@ -317,10 +587,18 @@ const checkRunnable = async ( env: Record, timeoutMs = 4000 ) => { + // Minimal environment to prevent credential leakage to potentially malicious binaries + const minimalEnv: Record = { + PATH: env.PATH, + HOME: env.HOME || env.USERPROFILE, + SystemRoot: env.SystemRoot, // Windows needs this + }; + for (const args of [["--version"], ["-v"]]) { - const result = await runProcess(commandPath, args, { env, timeoutMs }); - if (result.ok) { - return { runnable: true, reason: null }; + const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs }); + // Validate output: must be non-empty and reasonable length (< 4KB) + if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) { + return { runnable: true, reason: null, version: result.stdout.trim() }; } } return { runnable: false, reason: "healthcheck_failed" }; @@ -334,8 +612,28 @@ export const ensureCliConfigWriteAllowed = () => { return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; }; -export const getCliConfigHome = () => - String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir(); +export const getCliConfigHome = () => { + const override = String(process.env.CLI_CONFIG_HOME || "").trim(); + if (!override) return os.homedir(); + + // Must be absolute + if (!path.isAbsolute(override)) return os.homedir(); + + // Must not contain dangerous characters + if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir(); + + // Must not contain path traversal + if (path.normalize(override).includes("..")) return os.homedir(); + + // Must be within user's home directory (prevent reading from system dirs) + const home = os.homedir(); + const normalized = path.normalize(override); + if (!isPathWithin(normalized, home)) { + return home; // Silently fall back to home + } + + return normalized; +}; export const resolveOpencodeConfigDir = ( platform = process.platform, @@ -417,7 +715,7 @@ export const getCliRuntimeStatus = async (toolId: string) => { }; } - const located = await locateCommandCandidate(commands, env); + const located = await locateCommandCandidate(commands, env, toolId); const command = located.command; if (!located.installed) {