fix: (cli) secure CLI tool detection via known installation paths (Win… (#544)

fix(cli): secure CLI tool detection via known installation paths with security hardening — symlink validation, file-type checks, size bounds, minimal env in healthcheck for 8 CLI tools
This commit is contained in:
k0valik
2026-03-23 19:04:14 +01:00
committed by GitHub
parent 0fbabdcf25
commit 0fa3f9a057
2 changed files with 328 additions and 22 deletions

View File

@@ -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",

View File

@@ -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<string, string[]> = {
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<string, string | undef
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
};
/**
* Check if a command exists at a specific absolute path.
* Used for known installation locations.
*
* Security hardening:
* - Resolves symlinks and verifies target stays within expected directories
* - Verifies file is a regular file (not directory, pipe, or device)
* - Checks file size bounds (1KB - 100MB) to detect suspicious binaries
*/
const checkKnownPath = async (commandPath: string) => {
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<string, string | undefined>
env: Record<string, string | undefined>,
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<string, string | undefined>,
timeoutMs = 4000
) => {
// Minimal environment to prevent credential leakage to potentially malicious binaries
const minimalEnv: Record<string, string | undefined> = {
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) {