mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
306 lines
11 KiB
Swift
306 lines
11 KiB
Swift
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 = self.extractShellCommandFromArgv(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? {
|
|
guard let raw = command.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[..<end])
|
|
}
|
|
return String(rest)
|
|
}
|
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
|
}
|
|
|
|
private static func basenameLower(_ token: String) -> String {
|
|
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "" }
|
|
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
|
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
|
}
|
|
|
|
private static func extractShellCommandFromArgv(
|
|
command: [String],
|
|
rawCommand: String?) -> (isWrapper: Bool, command: String?)
|
|
{
|
|
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
|
return (false, nil)
|
|
}
|
|
let base0 = self.basenameLower(token0)
|
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
|
|
|
if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) {
|
|
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
guard flag == "-lc" || flag == "-c" else { return (false, nil) }
|
|
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
|
return (true, normalized)
|
|
}
|
|
|
|
if base0 == "cmd.exe" || base0 == "cmd" {
|
|
guard let idx = command
|
|
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
|
|
else {
|
|
return (false, nil)
|
|
}
|
|
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
|
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
|
return (true, normalized)
|
|
}
|
|
|
|
return (false, nil)
|
|
}
|
|
|
|
private enum ShellTokenContext {
|
|
case unquoted
|
|
case doubleQuoted
|
|
}
|
|
|
|
private struct ShellFailClosedRule {
|
|
let token: Character
|
|
let next: Character?
|
|
}
|
|
|
|
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
|
|
.unquoted: [
|
|
ShellFailClosedRule(token: "`", next: nil),
|
|
ShellFailClosedRule(token: "$", next: "("),
|
|
ShellFailClosedRule(token: "<", next: "("),
|
|
ShellFailClosedRule(token: ">", 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)
|
|
}
|
|
}
|