import fs from "node:fs"; import path from "node:path"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js"; import { expandHomePrefix } from "./home-dir.js"; export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"]; export type CommandResolution = { rawExecutable: string; resolvedPath?: string; executableName: string; effectiveArgv?: string[]; wrapperChain?: string[]; policyBlocked?: boolean; blockedWrapper?: string; }; function isExecutableFile(filePath: string): boolean { try { const stat = fs.statSync(filePath); if (!stat.isFile()) { return false; } if (process.platform !== "win32") { fs.accessSync(filePath, fs.constants.X_OK); } return true; } catch { return false; } } function parseFirstToken(command: string): string | null { const trimmed = command.trim(); if (!trimmed) { return null; } const first = trimmed[0]; if (first === '"' || first === "'") { const end = trimmed.indexOf(first, 1); if (end > 1) { return trimmed.slice(1, end); } return trimmed.slice(1); } const match = /^[^\s]+/.exec(trimmed); return match ? match[0] : null; } function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) { const expanded = rawExecutable.startsWith("~") ? expandHomePrefix(rawExecutable) : rawExecutable; if (expanded.includes("/") || expanded.includes("\\")) { if (path.isAbsolute(expanded)) { return isExecutableFile(expanded) ? expanded : undefined; } const base = cwd && cwd.trim() ? cwd.trim() : process.cwd(); const candidate = path.resolve(base, expanded); return isExecutableFile(candidate) ? candidate : undefined; } const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""; const entries = envPath.split(path.delimiter).filter(Boolean); const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0; const extensions = process.platform === "win32" ? hasExtension ? [""] : ( env?.PATHEXT ?? env?.Pathext ?? process.env.PATHEXT ?? process.env.Pathext ?? ".EXE;.CMD;.BAT;.COM" ) .split(";") .map((ext) => ext.toLowerCase()) : [""]; for (const entry of entries) { for (const ext of extensions) { const candidate = path.join(entry, expanded + ext); if (isExecutableFile(candidate)) { return candidate; } } } return undefined; } export function resolveCommandResolution( command: string, cwd?: string, env?: NodeJS.ProcessEnv, ): CommandResolution | null { const rawExecutable = parseFirstToken(command); if (!rawExecutable) { return null; } const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; return { rawExecutable, resolvedPath, executableName, effectiveArgv: [rawExecutable], wrapperChain: [], policyBlocked: false, }; } export function resolveCommandResolutionFromArgv( argv: string[], cwd?: string, env?: NodeJS.ProcessEnv, ): CommandResolution | null { const plan = resolveDispatchWrapperExecutionPlan(argv); const effectiveArgv = plan.argv; const rawExecutable = effectiveArgv[0]?.trim(); if (!rawExecutable) { return null; } const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; return { rawExecutable, resolvedPath, executableName, effectiveArgv, wrapperChain: plan.wrappers, policyBlocked: plan.policyBlocked, blockedWrapper: plan.blockedWrapper, }; } function normalizeMatchTarget(value: string): string { if (process.platform === "win32") { const stripped = value.replace(/^\\\\[?.]\\/, ""); return stripped.replace(/\\/g, "/").toLowerCase(); } return value.replace(/\\\\/g, "/").toLowerCase(); } function tryRealpath(value: string): string | null { try { return fs.realpathSync(value); } catch { return null; } } function globToRegExp(pattern: string): RegExp { let regex = "^"; let i = 0; while (i < pattern.length) { const ch = pattern[i]; if (ch === "*") { const next = pattern[i + 1]; if (next === "*") { regex += ".*"; i += 2; continue; } regex += "[^/]*"; i += 1; continue; } if (ch === "?") { regex += "."; i += 1; continue; } regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&"); i += 1; } regex += "$"; return new RegExp(regex, "i"); } function matchesPattern(pattern: string, target: string): boolean { const trimmed = pattern.trim(); if (!trimmed) { return false; } const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed; const hasWildcard = /[*?]/.test(expanded); let normalizedPattern = expanded; let normalizedTarget = target; if (process.platform === "win32" && !hasWildcard) { normalizedPattern = tryRealpath(expanded) ?? expanded; normalizedTarget = tryRealpath(target) ?? target; } normalizedPattern = normalizeMatchTarget(normalizedPattern); normalizedTarget = normalizeMatchTarget(normalizedTarget); const regex = globToRegExp(normalizedPattern); return regex.test(normalizedTarget); } export function resolveAllowlistCandidatePath( resolution: CommandResolution | null, cwd?: string, ): string | undefined { if (!resolution) { return undefined; } if (resolution.resolvedPath) { return resolution.resolvedPath; } const raw = resolution.rawExecutable?.trim(); if (!raw) { return undefined; } const expanded = raw.startsWith("~") ? expandHomePrefix(raw) : raw; if (!expanded.includes("/") && !expanded.includes("\\")) { return undefined; } if (path.isAbsolute(expanded)) { return expanded; } const base = cwd && cwd.trim() ? cwd.trim() : process.cwd(); return path.resolve(base, expanded); } export function matchAllowlist( entries: ExecAllowlistEntry[], resolution: CommandResolution | null, ): ExecAllowlistEntry | null { if (!entries.length || !resolution?.resolvedPath) { return null; } const resolvedPath = resolution.resolvedPath; for (const entry of entries) { const pattern = entry.pattern?.trim(); if (!pattern) { continue; } const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); if (!hasPath) { continue; } if (matchesPattern(pattern, resolvedPath)) { return entry; } } return null; } export type ExecArgvToken = | { kind: "empty"; raw: string; } | { kind: "terminator"; raw: string; } | { kind: "stdin"; raw: string; } | { kind: "positional"; raw: string; } | { kind: "option"; raw: string; style: "long"; flag: string; inlineValue?: string; } | { kind: "option"; raw: string; style: "short-cluster"; cluster: string; flags: string[]; }; /** * Tokenizes a single argv entry into a normalized option/positional model. * Consumers can share this model to keep argv parsing behavior consistent. */ export function parseExecArgvToken(raw: string): ExecArgvToken { if (!raw) { return { kind: "empty", raw }; } if (raw === "--") { return { kind: "terminator", raw }; } if (raw === "-") { return { kind: "stdin", raw }; } if (!raw.startsWith("-")) { return { kind: "positional", raw }; } if (raw.startsWith("--")) { const eqIndex = raw.indexOf("="); if (eqIndex > 0) { return { kind: "option", raw, style: "long", flag: raw.slice(0, eqIndex), inlineValue: raw.slice(eqIndex + 1), }; } return { kind: "option", raw, style: "long", flag: raw }; } const cluster = raw.slice(1); return { kind: "option", raw, style: "short-cluster", cluster, flags: cluster.split("").map((entry) => `-${entry}`), }; }