import Foundation struct ExecCommandResolution: Sendable { let rawExecutable: String let resolvedPath: String? let executableName: String let cwd: String? static func resolve( command: [String], rawCommand: String?, cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) } return self.resolve(command: command, cwd: cwd, env: env) } static func resolveForAllowlist( command: [String], rawCommand: String?, cwd: String?, env: [String: String]?) -> [ExecCommandResolution] { let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand) if shell.isWrapper { guard let shellCommand = shell.command, let segments = self.splitShellCommandChain(shellCommand) else { // Fail closed: if we cannot safely parse a shell wrapper payload, // treat this as an allowlist miss and require approval. return [] } var resolutions: [ExecCommandResolution] = [] resolutions.reserveCapacity(segments.count) for segment in segments { guard let token = self.parseFirstToken(segment), let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) else { return [] } resolutions.append(resolution) } return resolutions } guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else { return [] } return [resolution] } static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) } private static func resolveExecutable( rawExecutable: String, cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") let resolvedPath: String? = { if hasPathSeparator { if expanded.hasPrefix("/") { return expanded } let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath return URL(fileURLWithPath: root).appendingPathComponent(expanded).path } let searchPaths = self.searchPaths(from: env) return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) }() let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded return ExecCommandResolution( rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd) } private static func parseFirstToken(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard let first = trimmed.first else { return nil } if first == "\"" || first == "'" { let rest = trimmed.dropFirst() if let end = rest.firstIndex(of: first) { return String(rest[..", next: "("), ], .doubleQuoted: [ ShellFailClosedRule(token: "`", next: nil), ShellFailClosedRule(token: "$", next: "("), ], ] private static func splitShellCommandChain(_ command: String) -> [String]? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } var segments: [String] = [] var current = "" var inSingle = false var inDouble = false var escaped = false let chars = Array(trimmed) var idx = 0 func appendCurrent() -> Bool { let segment = current.trimmingCharacters(in: .whitespacesAndNewlines) guard !segment.isEmpty else { return false } segments.append(segment) current.removeAll(keepingCapacity: true) return true } while idx < chars.count { let ch = chars[idx] let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil if escaped { current.append(ch) escaped = false idx += 1 continue } if ch == "\\", !inSingle { current.append(ch) escaped = true idx += 1 continue } if ch == "'", !inDouble { inSingle.toggle() current.append(ch) idx += 1 continue } if ch == "\"", !inSingle { inDouble.toggle() current.append(ch) idx += 1 continue } if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) { // Fail closed on command/process substitution in allowlist mode, // including command substitution inside double-quoted shell strings. return nil } if !inSingle, !inDouble { let prev: Character? = idx > 0 ? chars[idx - 1] : nil if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) { guard appendCurrent() else { return nil } idx += delimiterStep continue } } current.append(ch) idx += 1 } if escaped || inSingle || inDouble { return nil } guard appendCurrent() else { return nil } return segments } private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool { let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted guard let rules = self.shellFailClosedRules[context] else { return false } for rule in rules { if ch == rule.token, rule.next == nil || next == rule.next { return true } } return false } private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? { if ch == ";" || ch == "\n" { return 1 } if ch == "&" { if next == "&" { return 2 } // Keep fd redirections like 2>&1 or &>file intact. let prevIsRedirect = prev == ">" let nextIsRedirect = next == ">" return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil } if ch == "|" { if next == "|" || next == "&" { return 2 } return 1 } return nil } private static func searchPaths(from env: [String: String]?) -> [String] { let raw = env?["PATH"] if let raw, !raw.isEmpty { return raw.split(separator: ":").map(String.init) } return CommandResolver.preferredPaths() } } enum ExecCommandFormatter { static func displayString(for argv: [String]) -> String { argv.map { arg in let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "\"\"" } let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } if !needsQuotes { return trimmed } let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") return "\"\(escaped)\"" }.joined(separator: " ") } static func displayString(for argv: [String], rawCommand: String?) -> String { let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmed.isEmpty { return trimmed } return self.displayString(for: argv) } }