From 5b704a6ea4b67a9c593ee227733276b6e8fc0872 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 19 Mar 2026 14:14:28 +0200 Subject: [PATCH] fix(macos): align exec wrapper resolution --- .../OpenClaw/ExecCommandResolution.swift | 65 +- .../OpenClaw/ExecEnvInvocationUnwrapper.swift | 20 +- .../OpenClaw/ExecShellWrapperParser.swift | 97 +-- .../ExecSystemRunCommandValidator.swift | 316 +-------- .../OpenClaw/ExecWrapperResolution.swift | 651 ++++++++++++++++++ .../OpenClawIPCTests/ExecAllowlistTests.swift | 88 +++ .../ExecSystemRunCommandValidatorTests.swift | 32 + 7 files changed, 798 insertions(+), 471 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 131868bb23e..22af07f2ba2 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -70,7 +70,7 @@ struct ExecCommandResolution { } static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { - let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) + let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(command) guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } @@ -111,7 +111,7 @@ struct ExecCommandResolution { { let tokens = self.tokenizeShellWords(segment) guard !tokens.isEmpty else { return nil } - let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens) + let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(tokens) guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } @@ -130,30 +130,36 @@ struct ExecCommandResolution { return } - if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), - ExecCommandToken.basenameLower(token0) == "env", - let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command), - !envUnwrapped.isEmpty - { + switch ExecWrapperResolution.unwrapKnownDispatchWrapperInvocation(command) { + case .blocked: + return + case let .unwrapped(_, argv): self.collectAllowAlwaysPatterns( - command: envUnwrapped, + command: argv, cwd: cwd, env: env, depth: depth + 1, patterns: &patterns, seen: &seen) return + case .notWrapper: + break } - if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) { + switch ExecWrapperResolution.unwrapKnownShellMultiplexerInvocation(command) { + case .blocked: + return + case let .unwrapped(_, argv): self.collectAllowAlwaysPatterns( - command: shellMultiplexer, + command: argv, cwd: cwd, env: env, depth: depth + 1, patterns: &patterns, seen: &seen) return + case .notWrapper: + break } let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) @@ -188,45 +194,6 @@ struct ExecCommandResolution { patterns.append(pattern) } - private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { - guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { - return nil - } - let wrapper = ExecCommandToken.basenameLower(token0) - guard wrapper == "busybox" || wrapper == "toybox" else { - return nil - } - - var appletIndex = 1 - if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { - appletIndex += 1 - } - guard appletIndex < argv.count else { - return nil - } - let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) - guard !applet.isEmpty else { - return nil - } - - let normalizedApplet = ExecCommandToken.basenameLower(applet) - let shellWrappers = Set([ - "ash", - "bash", - "dash", - "fish", - "ksh", - "powershell", - "pwsh", - "sh", - "zsh", - ]) - guard shellWrappers.contains(normalizedApplet) else { - return nil - } - return Array(argv[appletIndex...]) - } - private static func parseFirstToken(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift index 35423182b6e..3db3589d0b9 100644 --- a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -90,24 +90,6 @@ enum ExecEnvInvocationUnwrapper { } static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { - var current = command - var depth = 0 - while depth < self.maxWrapperDepth { - guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { - break - } - guard ExecCommandToken.basenameLower(token) == "env" else { - break - } - guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else { - break - } - if unwrapped.usesModifiers { - break - } - current = unwrapped.command - depth += 1 - } - return current + ExecWrapperResolution.unwrapDispatchWrappersForResolution(command) } } diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift index 06851a7d065..6bdda2d15a3 100644 --- a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -8,101 +8,8 @@ enum ExecShellWrapperParser { static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil) } - private enum Kind { - case posix - case cmd - case powershell - } - - private struct WrapperSpec { - let kind: Kind - let names: Set - } - - private static let posixInlineFlags = Set(["-lc", "-c", "--command"]) - private static let powershellInlineFlags = Set(["-c", "-command", "--command"]) - - private static let wrapperSpecs: [WrapperSpec] = [ - WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]), - WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), - WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), - ] - static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper { - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw - return self.extract(command: command, preferredRaw: preferredRaw, depth: 0) - } - - private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper { - guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else { - return .notWrapper - } - guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { - return .notWrapper - } - - let base0 = ExecCommandToken.basenameLower(token0) - if base0 == "env" { - guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else { - return .notWrapper - } - return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1) - } - - guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else { - return .notWrapper - } - guard let payload = self.extractPayload(command: command, spec: spec) else { - return .notWrapper - } - let normalized = preferredRaw ?? payload - return ParsedShellWrapper(isWrapper: true, command: normalized) - } - - private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { - switch spec.kind { - case .posix: - self.extractPosixInlineCommand(command) - case .cmd: - self.extractCmdInlineCommand(command) - case .powershell: - self.extractPowerShellInlineCommand(command) - } - } - - private static func extractPosixInlineCommand(_ command: [String]) -> String? { - let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" - guard self.posixInlineFlags.contains(flag.lowercased()) else { - return nil - } - let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" - return payload.isEmpty ? nil : payload - } - - private static func extractCmdInlineCommand(_ command: [String]) -> String? { - guard let idx = command - .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) - else { - return nil - } - let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") - let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) - return payload.isEmpty ? nil : payload - } - - private static func extractPowerShellInlineCommand(_ command: [String]) -> String? { - for idx in 1.. ValidationResult { let normalizedRaw = self.normalizeRaw(rawCommand) let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) @@ -116,148 +94,14 @@ enum ExecSystemRunCommandValidator { return normalizedRaw == previewCommand ? normalizedRaw : nil } - private static func normalizeExecutableToken(_ token: String) -> String { - let base = ExecCommandToken.basenameLower(token) - if base.hasSuffix(".exe") { - return String(base.dropLast(4)) - } - return base - } - - private static func isEnvAssignment(_ token: String) -> Bool { - token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil - } - - private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { - ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) } - } - - private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { - var idx = 1 - var expectsOptionValue = false - var usesModifiers = false - - while idx < argv.count { - let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - idx += 1 - continue - } - if expectsOptionValue { - expectsOptionValue = false - usesModifiers = true - idx += 1 - continue - } - if token == "--" || token == "-" { - idx += 1 - break - } - if self.isEnvAssignment(token) { - usesModifiers = true - idx += 1 - continue - } - if !token.hasPrefix("-") || token == "-" { - break - } - - let lower = token.lowercased() - let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower - if ExecEnvOptions.flagOnly.contains(flag) { - usesModifiers = true - idx += 1 - continue - } - if ExecEnvOptions.withValue.contains(flag) { - usesModifiers = true - if !lower.contains("=") { - expectsOptionValue = true - } - idx += 1 - continue - } - if self.hasEnvInlineValuePrefix(lower) { - usesModifiers = true - idx += 1 - continue - } - return nil - } - - if expectsOptionValue { - return nil - } - guard idx < argv.count else { - return nil - } - return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers) - } - - private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { - guard let token0 = self.trimmedNonEmpty(argv.first) else { - return nil - } - let wrapper = self.normalizeExecutableToken(token0) - guard self.shellMultiplexerWrapperNames.contains(wrapper) else { - return nil - } - - var appletIndex = 1 - if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { - appletIndex += 1 - } - guard appletIndex < argv.count else { - return nil - } - let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) - guard !applet.isEmpty else { - return nil - } - let normalizedApplet = self.normalizeExecutableToken(applet) - guard self.shellWrapperNames.contains(normalizedApplet) else { - return nil - } - return Array(argv[appletIndex...]) - } - private static func hasEnvManipulationBeforeShellWrapper( _ argv: [String], depth: Int = 0, envManipulationSeen: Bool = false) -> Bool { - if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth { - return false - } - guard let token0 = self.trimmedNonEmpty(argv.first) else { - return false - } - - let normalized = self.normalizeExecutableToken(token0) - if normalized == "env" { - guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else { - return false - } - return self.hasEnvManipulationBeforeShellWrapper( - envUnwrap.argv, - depth: depth + 1, - envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers) - } - - if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) { - return self.hasEnvManipulationBeforeShellWrapper( - shellMultiplexer, - depth: depth + 1, - envManipulationSeen: envManipulationSeen) - } - - guard self.shellWrapperNames.contains(normalized) else { - return false - } - guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else { - return false - } - return envManipulationSeen + _ = depth + _ = envManipulationSeen + return ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(argv) } private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool { @@ -265,22 +109,14 @@ enum ExecSystemRunCommandValidator { guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else { return false } - let wrapper = self.normalizeExecutableToken(token0) + let wrapper = ExecWrapperResolution.normalizeExecutableToken(token0) guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else { return false } - let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { - self.resolveInlineCommandTokenIndex( - wrapperArgv, - flags: self.powershellInlineCommandFlags, - allowCombinedC: false) - } else { - self.resolveInlineCommandTokenIndex( - wrapperArgv, - flags: self.posixInlineCommandFlags, - allowCombinedC: true) - } + let inlineCommandIndex = ExecWrapperResolution.resolveInlineCommandValueTokenIndex( + wrapperArgv, + normalizedWrapper: wrapper) guard let inlineCommandIndex else { return false } @@ -292,142 +128,6 @@ enum ExecSystemRunCommandValidator { } private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] { - var current = argv - for _ in 0.., - allowCombinedC: Bool) -> InlineCommandTokenMatch? - { - var idx = 1 - while idx < argv.count { - let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - idx += 1 - continue - } - let lower = token.lowercased() - if lower == "--" { - break - } - if flags.contains(lower) { - return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil) - } - if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { - let inline = String(token.dropFirst(inlineOffset)) - .trimmingCharacters(in: .whitespacesAndNewlines) - return InlineCommandTokenMatch( - tokenIndex: idx, - inlineCommand: inline.isEmpty ? nil : inline) - } - idx += 1 - } - return nil - } - - private static func resolveInlineCommandTokenIndex( - _ argv: [String], - flags: Set, - allowCombinedC: Bool) -> Int? - { - guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { - return nil - } - if match.inlineCommand != nil { - return match.tokenIndex - } - let nextIndex = match.tokenIndex + 1 - return nextIndex < argv.count ? nextIndex : nil - } - - private static func combinedCommandInlineOffset(_ token: String) -> Int? { - let chars = Array(token.lowercased()) - guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { - return nil - } - if chars.dropFirst().contains("-") { - return nil - } - guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { - return nil - } - return commandIndex + 1 - } - - private static func extractShellInlinePayload( - _ argv: [String], - normalizedWrapper: String) -> String? - { - if normalizedWrapper == "cmd" { - return self.extractCmdInlineCommand(argv) - } - if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { - return self.extractInlineCommandByFlags( - argv, - flags: self.powershellInlineCommandFlags, - allowCombinedC: false) - } - return self.extractInlineCommandByFlags( - argv, - flags: self.posixInlineCommandFlags, - allowCombinedC: true) - } - - private static func extractInlineCommandByFlags( - _ argv: [String], - flags: Set, - allowCombinedC: Bool) -> String? - { - guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { - return nil - } - if let inlineCommand = match.inlineCommand { - return inlineCommand - } - let nextIndex = match.tokenIndex + 1 - return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil) - } - - private static func extractCmdInlineCommand(_ argv: [String]) -> String? { - guard let idx = argv.firstIndex(where: { - let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return token == "/c" || token == "/k" - }) else { - return nil - } - let tailIndex = idx + 1 - guard tailIndex < argv.count else { - return nil - } - let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - return payload.isEmpty ? nil : payload + ExecWrapperResolution.unwrapShellInspectionArgv(argv) } } diff --git a/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift b/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift new file mode 100644 index 00000000000..8f0dd8337b6 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift @@ -0,0 +1,651 @@ +import Foundation + +enum ExecWrapperResolution { + static let maxWrapperDepth = ExecEnvInvocationUnwrapper.maxWrapperDepth + + struct ShellWrapperCommand { + let isWrapper: Bool + let command: String? + + static let notWrapper = ShellWrapperCommand(isWrapper: false, command: nil) + } + + enum ShellMultiplexerUnwrapResult { + case notWrapper + case blocked(wrapper: String) + case unwrapped(wrapper: String, argv: [String]) + } + + enum DispatchWrapperUnwrapResult { + case notWrapper + case blocked(wrapper: String) + case unwrapped(wrapper: String, argv: [String]) + } + + struct DispatchWrapperExecutionPlan { + let argv: [String] + let wrappers: [String] + let policyBlocked: Bool + let blockedWrapper: String? + } + + private enum ShellWrapperKind { + case posix + case cmd + case powershell + } + + private struct ShellWrapperSpec { + let kind: ShellWrapperKind + let names: Set + } + + private enum WrapperScanDirective { + case continueScan + case consumeNext + case stop + case invalid + } + + private struct InlineCommandMatch { + let tokenIndex: Int + let inlineCommand: String? + } + + private static let posixInlineFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineFlags = Set([ + "-c", + "-command", + "--command", + "-f", + "-file", + "-encodedcommand", + "-enc", + "-e", + ]) + + private static let shellWrapperNames = Set([ + "ash", + "bash", + "cmd", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + + private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"]) + private static let transparentDispatchWrappers = Set(["nice", "nohup", "stdbuf", "timeout"]) + private static let niceOptionsWithValue = Set(["-n", "--adjustment", "--priority"]) + private static let stdbufOptionsWithValue = Set(["-i", "--input", "-o", "--output", "-e", "--error"]) + private static let timeoutFlagOptions = Set(["--foreground", "--preserve-status", "-v", "--verbose"]) + private static let timeoutOptionsWithValue = Set(["-k", "--kill-after", "-s", "--signal"]) + + private static let shellWrapperSpecs: [ShellWrapperSpec] = [ + ShellWrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]), + ShellWrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), + ShellWrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), + ] + + static func normalizeExecutableToken(_ token: String) -> String { + let base = ExecCommandToken.basenameLower(token) + if base.hasSuffix(".exe") { + return String(base.dropLast(4)) + } + return base + } + + static func isShellWrapperExecutable(_ token: String) -> Bool { + self.shellWrapperNames.contains(self.normalizeExecutableToken(token)) + } + + static func extractShellWrapperCommand(_ argv: [String], rawCommand: String?) -> ShellWrapperCommand { + self.extractShellWrapperCommandInternal( + argv, + rawCommand: self.normalizeRawCommand(rawCommand), + depth: 0) + } + + static func extractShellInlinePayload(_ argv: [String], normalizedWrapper: String) -> String? { + if normalizedWrapper == "cmd" { + return self.extractCmdInlineCommand(argv) + } + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineFlags, + allowCombinedC: false) + } + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineFlags, + allowCombinedC: true) + } + + static func resolveInlineCommandValueTokenIndex( + _ argv: [String], + normalizedWrapper: String) -> Int? + { + if normalizedWrapper == "cmd" { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let nextIndex = idx + 1 + return nextIndex < argv.count ? nextIndex : nil + } + + let flags: Set + let allowCombinedC: Bool + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + flags = self.powershellInlineFlags + allowCombinedC = false + } else { + flags = self.posixInlineFlags + allowCombinedC = true + } + + guard let match = self.findInlineCommandMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { + return nil + } + if match.inlineCommand != nil { + return match.tokenIndex + } + let nextIndex = match.tokenIndex + 1 + return nextIndex < argv.count ? nextIndex : nil + } + + static func unwrapKnownShellMultiplexerInvocation(_ argv: [String]) -> ShellMultiplexerUnwrapResult { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return .notWrapper + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.shellMultiplexerWrapperNames.contains(wrapper) else { + return .notWrapper + } + + var appletIndex = 1 + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return .blocked(wrapper: wrapper) + } + + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty, self.isShellWrapperExecutable(applet) else { + return .blocked(wrapper: wrapper) + } + + let unwrapped = Array(argv[appletIndex...]) + guard !unwrapped.isEmpty else { + return .blocked(wrapper: wrapper) + } + return .unwrapped(wrapper: wrapper, argv: unwrapped) + } + + static func unwrapKnownDispatchWrapperInvocation(_ argv: [String]) -> DispatchWrapperUnwrapResult { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return .notWrapper + } + let wrapper = self.normalizeExecutableToken(token0) + switch wrapper { + case "env": + return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: ExecEnvInvocationUnwrapper.unwrap(argv)) + case "nice": + return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapNiceInvocation(argv)) + case "nohup": + return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapNohupInvocation(argv)) + case "stdbuf": + return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapStdbufInvocation(argv)) + case "timeout": + return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapTimeoutInvocation(argv)) + case "chrt", "doas", "ionice", "setsid", "sudo", "taskset": + return .blocked(wrapper: wrapper) + default: + return .notWrapper + } + } + + static func resolveDispatchWrapperExecutionPlan( + _ argv: [String], + maxDepth: Int = ExecEnvInvocationUnwrapper.maxWrapperDepth) -> DispatchWrapperExecutionPlan + { + var current = argv + var wrappers: [String] = [] + + for _ in 0..= maxDepth { + let overflow = self.unwrapKnownDispatchWrapperInvocation(current) + switch overflow { + case let .blocked(wrapper), let .unwrapped(wrapper, _): + return DispatchWrapperExecutionPlan( + argv: current, + wrappers: wrappers, + policyBlocked: true, + blockedWrapper: wrapper) + case .notWrapper: + break + } + } + + return DispatchWrapperExecutionPlan( + argv: current, + wrappers: wrappers, + policyBlocked: false, + blockedWrapper: nil) + } + + static func unwrapDispatchWrappersForResolution( + _ argv: [String], + maxDepth: Int = ExecEnvInvocationUnwrapper.maxWrapperDepth) -> [String] + { + self.resolveDispatchWrapperExecutionPlan(argv, maxDepth: maxDepth).argv + } + + static func unwrapShellInspectionArgv(_ argv: [String]) -> [String] { + var current = self.unwrapDispatchWrappersForResolution(argv) + for _ in 0.. Bool { + self.hasEnvManipulationBeforeShellWrapperInternal( + argv, + depth: 0, + envManipulationSeen: false) + } + + private static func normalizeRawCommand(_ rawCommand: String?) -> String? { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func splitFlag(_ lowerToken: String) -> String { + lowerToken.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lowerToken + } + + private static func scanWrapperInvocation( + _ argv: [String], + separators: Set = [], + onToken: (String, String) -> WrapperScanDirective, + adjustCommandIndex: ((Int, [String]) -> Int?)? = nil) -> [String]? + { + var idx = 1 + var expectsOptionValue = false + + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + idx += 1 + continue + } + if separators.contains(token) { + idx += 1 + break + } + + let directive = onToken(token, token.lowercased()) + switch directive { + case .stop: + break + case .invalid: + return nil + case .consumeNext: + expectsOptionValue = true + case .continueScan: + break + } + + if directive == .stop { + break + } + idx += 1 + } + + if expectsOptionValue { + return nil + } + + let commandIndex = adjustCommandIndex?(idx, argv) ?? idx + guard commandIndex < argv.count else { + return nil + } + return Array(argv[commandIndex...]) + } + + private static func unwrapDashOptionInvocation( + _ argv: [String], + onFlag: (String, String) -> WrapperScanDirective, + adjustCommandIndex: ((Int, [String]) -> Int?)? = nil) -> [String]? + { + self.scanWrapperInvocation( + argv, + separators: ["--"], + onToken: { token, lower in + if !token.hasPrefix("-") || token == "-" { + return .stop + } + return onFlag(self.splitFlag(lower), lower) + }, + adjustCommandIndex: adjustCommandIndex) + } + + private static func envInvocationUsesModifiers(_ argv: [String]) -> Bool { + ExecEnvInvocationUnwrapper.unwrapWithMetadata(argv)?.usesModifiers ?? true + } + + private static func unwrapNiceInvocation(_ argv: [String]) -> [String]? { + self.unwrapDashOptionInvocation(argv) { flag, lower in + if lower.range(of: #"^-\d+$"#, options: .regularExpression) != nil { + return .continueScan + } + if self.niceOptionsWithValue.contains(flag) { + return lower.contains("=") || lower != flag ? .continueScan : .consumeNext + } + if lower.hasPrefix("-n"), lower.count > 2 { + return .continueScan + } + return .invalid + } + } + + private static func unwrapNohupInvocation(_ argv: [String]) -> [String]? { + self.scanWrapperInvocation( + argv, + separators: ["--"], + onToken: { token, lower in + if !token.hasPrefix("-") || token == "-" { + return .stop + } + return lower == "--help" || lower == "--version" ? .continueScan : .invalid + }) + } + + private static func unwrapStdbufInvocation(_ argv: [String]) -> [String]? { + self.unwrapDashOptionInvocation(argv) { flag, lower in + if !self.stdbufOptionsWithValue.contains(flag) { + return .invalid + } + return lower.contains("=") ? .continueScan : .consumeNext + } + } + + private static func unwrapTimeoutInvocation(_ argv: [String]) -> [String]? { + self.unwrapDashOptionInvocation( + argv, + onFlag: { flag, lower in + if self.timeoutFlagOptions.contains(flag) { + return .continueScan + } + if self.timeoutOptionsWithValue.contains(flag) { + return lower.contains("=") ? .continueScan : .consumeNext + } + return .invalid + }, + adjustCommandIndex: { commandIndex, currentArgv in + let wrappedCommandIndex = commandIndex + 1 + return wrappedCommandIndex < currentArgv.count ? wrappedCommandIndex : nil + }) + } + + private static func unwrapDispatchWrapper( + wrapper: String, + unwrapped: [String]?) -> DispatchWrapperUnwrapResult + { + guard let unwrapped, !unwrapped.isEmpty else { + return .blocked(wrapper: wrapper) + } + return .unwrapped(wrapper: wrapper, argv: unwrapped) + } + + private static func isSemanticDispatchWrapperUsage(wrapper: String, argv: [String]) -> Bool { + if wrapper == "env" { + return self.envInvocationUsesModifiers(argv) + } + return !self.transparentDispatchWrappers.contains(wrapper) + } + + private static func findShellWrapperSpec(_ baseExecutable: String) -> ShellWrapperSpec? { + self.shellWrapperSpecs.first { $0.names.contains(baseExecutable) } + } + + private static func extractShellWrapperPayload(_ argv: [String], spec: ShellWrapperSpec) -> String? { + switch spec.kind { + case .posix: + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineFlags, + allowCombinedC: true) + case .cmd: + return self.extractCmdInlineCommand(argv) + case .powershell: + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineFlags, + allowCombinedC: false) + } + } + + private static func extractShellWrapperCommandInternal( + _ argv: [String], + rawCommand: String?, + depth: Int) -> ShellWrapperCommand + { + if depth > self.maxWrapperDepth { + return .notWrapper + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return .notWrapper + } + + switch self.unwrapKnownDispatchWrapperInvocation(argv) { + case .blocked: + return .notWrapper + case let .unwrapped(_, argv): + return self.extractShellWrapperCommandInternal( + argv, + rawCommand: rawCommand, + depth: depth + 1) + case .notWrapper: + break + } + + switch self.unwrapKnownShellMultiplexerInvocation(argv) { + case .blocked: + return .notWrapper + case let .unwrapped(_, argv): + return self.extractShellWrapperCommandInternal( + argv, + rawCommand: rawCommand, + depth: depth + 1) + case .notWrapper: + break + } + + let base0 = self.normalizeExecutableToken(token0) + guard let wrapper = self.findShellWrapperSpec(base0), + let payload = self.extractShellWrapperPayload(argv, spec: wrapper) + else { + return .notWrapper + } + + return ShellWrapperCommand( + isWrapper: true, + command: rawCommand ?? payload) + } + + private static func hasEnvManipulationBeforeShellWrapperInternal( + _ argv: [String], + depth: Int, + envManipulationSeen: Bool) -> Bool + { + if depth > self.maxWrapperDepth { + return false + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return false + } + + switch self.unwrapKnownDispatchWrapperInvocation(argv) { + case .blocked: + return false + case let .unwrapped(wrapper, unwrappedArgv): + let nextEnvManipulationSeen = envManipulationSeen || ( + wrapper == "env" && self.envInvocationUsesModifiers(argv) + ) + return self.hasEnvManipulationBeforeShellWrapperInternal( + unwrappedArgv, + depth: depth + 1, + envManipulationSeen: nextEnvManipulationSeen) + case .notWrapper: + break + } + + switch self.unwrapKnownShellMultiplexerInvocation(argv) { + case .blocked: + return false + case let .unwrapped(_, argv): + return self.hasEnvManipulationBeforeShellWrapperInternal( + argv, + depth: depth + 1, + envManipulationSeen: envManipulationSeen) + case .notWrapper: + break + } + + let normalized = self.normalizeExecutableToken(token0) + guard let spec = self.findShellWrapperSpec(normalized), + self.extractShellWrapperPayload(argv, spec: spec) != nil + else { + return false + } + return envManipulationSeen + } + + private static func findInlineCommandMatch( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> InlineCommandMatch? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return InlineCommandMatch(tokenIndex: idx, inlineCommand: nil) + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return InlineCommandMatch( + tokenIndex: idx, + inlineCommand: inline.isEmpty ? nil : inline) + } + idx += 1 + } + return nil + } + + private static func extractInlineCommandByFlags( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + guard let match = self.findInlineCommandMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { + return nil + } + if let inlineCommand = match.inlineCommand { + return inlineCommand + } + let nextIndex = match.tokenIndex + 1 + return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil) + } + + private static func combinedCommandInlineOffset(_ token: String) -> Int? { + let chars = Array(token.lowercased()) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + if chars.dropFirst().contains("-") { + return nil + } + guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { + return nil + } + return commandIndex + 1 + } + + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let tailIndex = idx + 1 + guard tailIndex < argv.count else { + return nil + } + let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index dc2ab9c42d7..6a1ed72bd5f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -228,6 +228,42 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } + @Test func `resolve for allowlist unwraps busybox shell applets`() throws { + let tmp = try makeTempDirForTests() + let busybox = tmp.appendingPathComponent("busybox") + let whoami = tmp.appendingPathComponent("whoami") + try makeExecutableForTests(at: busybox) + try makeExecutableForTests(at: whoami) + + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: [busybox.path, "sh", "-lc", "echo allowlisted && whoami"], + rawCommand: nil, + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].resolvedPath == whoami.path) + #expect(resolutions[1].executableName == "whoami") + } + + @Test func `resolve for allowlist unwraps dispatch wrappers before shell wrappers`() throws { + let tmp = try makeTempDirForTests() + let whoami = tmp.appendingPathComponent("whoami") + try makeExecutableForTests(at: whoami) + + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: ["/usr/bin/nice", "/bin/zsh", "-lc", "echo allowlisted && whoami"], + rawCommand: nil, + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].resolvedPath == whoami.path) + #expect(resolutions[1].executableName == "whoami") + } + @Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() { let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -289,6 +325,58 @@ struct ExecAllowlistTests { #expect(patterns == ["/usr/bin/printf"]) } + @Test func `allow always patterns unwrap dispatch wrappers before shell wrappers`() throws { + let tmp = try makeTempDirForTests() + let whoami = tmp.appendingPathComponent("whoami") + try makeExecutableForTests(at: whoami) + + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(patterns == [whoami.path]) + #expect(!patterns.contains("/usr/bin/nice")) + } + + @Test func `allow always patterns unwrap busybox shell applets to inner executables`() throws { + let tmp = try makeTempDirForTests() + let busybox = tmp.appendingPathComponent("busybox") + let whoami = tmp.appendingPathComponent("whoami") + try makeExecutableForTests(at: busybox) + try makeExecutableForTests(at: whoami) + + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: [busybox.path, "sh", "-lc", "whoami"], + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(patterns == [whoami.path]) + #expect(!patterns.contains(busybox.path)) + } + + @Test func `allow always patterns fail closed for unsupported busybox applets`() throws { + let tmp = try makeTempDirForTests() + let busybox = tmp.appendingPathComponent("busybox") + try makeExecutableForTests(at: busybox) + + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: [busybox.path, "sed", "-n", "1p"], + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(patterns.isEmpty) + } + + @Test func `allow always patterns fail closed for blocked dispatch wrappers`() { + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["sudo", "/bin/zsh", "-lc", "whoami"], + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + + #expect(patterns.isEmpty) + } + @Test func `match all requires every segment to match`() { let first = ExecCommandResolution( rawExecutable: "echo", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index 2b07d928ccf..ae8dfb8aa95 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -64,6 +64,38 @@ struct ExecSystemRunCommandValidatorTests { } } + @Test func `validator keeps busybox shell wrapper text out of allowlist raw parsing`() throws { + let tmp = try makeTempDirForTests() + let busybox = tmp.appendingPathComponent("busybox") + try makeExecutableForTests(at: busybox) + + let command = [busybox.path, "sh", "-lc", "/usr/bin/printf ok"] + let rawCommand = "\(busybox.path) sh -lc \"/usr/bin/printf ok\"" + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand) + + switch result { + case let .ok(resolved): + #expect(resolved.displayCommand == rawCommand) + #expect(resolved.evaluationRawCommand == nil) + case let .invalid(message): + Issue.record("unexpected invalid result: \(message)") + } + } + + @Test func `validator keeps dispatch wrapper shell text out of allowlist raw parsing`() { + let command = ["/usr/bin/nice", "/bin/sh", "-lc", "/usr/bin/printf ok"] + let rawCommand = "/usr/bin/nice /bin/sh -lc \"/usr/bin/printf ok\"" + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand) + + switch result { + case let .ok(resolved): + #expect(resolved.displayCommand == rawCommand) + #expect(resolved.evaluationRawCommand == nil) + case let .invalid(message): + Issue.record("unexpected invalid result: \(message)") + } + } + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { let fixtureURL = try self.findContractFixtureURL() let data = try Data(contentsOf: fixtureURL)