diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift index ad40d2c3803..908658cfdbe 100644 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -5,12 +5,20 @@ enum ExecAllowlistMatcher { guard let resolution, !entries.isEmpty else { return nil } let rawExecutable = resolution.rawExecutable let resolvedPath = resolution.resolvedPath + let scriptCandidatePath = resolution.scriptCandidatePath for entry in entries { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { case let .valid(pattern): - let target = resolvedPath ?? rawExecutable - if self.matches(pattern: pattern, target: target) { return entry } + let primaryTarget = resolvedPath ?? rawExecutable + if self.matches(pattern: pattern, target: primaryTarget) { + return entry + } + if let scriptCandidatePath, + self.matches(pattern: pattern, target: scriptCandidatePath) + { + return entry + } case .invalid: continue } diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 42a946f5317..921e5ebf658 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -3,9 +3,26 @@ import Foundation struct ExecCommandResolution { let rawExecutable: String let resolvedPath: String? + let scriptCandidatePath: String? let executableName: String let cwd: String? + private static let maxAllowAlwaysTraversalDepth = 2 + + init( + rawExecutable: String, + resolvedPath: String?, + scriptCandidatePath: String? = nil, + executableName: String, + cwd: String?) + { + self.rawExecutable = rawExecutable + self.resolvedPath = resolvedPath + self.scriptCandidatePath = scriptCandidatePath + self.executableName = executableName + self.cwd = cwd + } + static func resolve( command: [String], rawCommand: String?, @@ -22,10 +39,18 @@ struct ExecCommandResolution { let normalizedToken = ExecWrapperResolution.normalizeExecutableToken(token) let normalizedEffective = ExecWrapperResolution.normalizeExecutableToken(effectiveRaw) if normalizedToken == normalizedEffective { - return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + return self.attachingScriptCandidatePath( + to: resolution, + command: command, + cwd: cwd) } } - return self.resolveExecutable(rawExecutable: effectiveRaw, cwd: cwd, env: env) + let resolution = self.resolveExecutable(rawExecutable: effectiveRaw, cwd: cwd, env: env) + return self.attachingScriptCandidatePath( + to: resolution, + command: command, + cwd: cwd) } static func resolveForAllowlist( @@ -105,7 +130,11 @@ struct ExecCommandResolution { guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + let resolution = self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + return self.attachingScriptCandidatePath( + to: resolution, + command: command, + cwd: cwd) } private static func resolveExecutable( @@ -162,7 +191,7 @@ struct ExecCommandResolution { patterns: inout [String], seen: inout Set) { - guard depth <= ExecWrapperResolution.maxWrapperDepth, !command.isEmpty else { + guard depth <= Self.maxAllowAlwaysTraversalDepth, !command.isEmpty else { return } @@ -226,6 +255,13 @@ struct ExecCommandResolution { return } + if let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd), + seen.insert(scriptCandidatePath).inserted + { + patterns.append(scriptCandidatePath) + return + } + guard let resolution = self.resolve(command: command, cwd: cwd, env: env), let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution), seen.insert(pattern).inserted @@ -235,6 +271,25 @@ struct ExecCommandResolution { patterns.append(pattern) } + private static func attachingScriptCandidatePath( + to resolution: ExecCommandResolution?, + command: [String], + cwd: String?) -> ExecCommandResolution? + { + guard let resolution else { + return nil + } + guard let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd) else { + return resolution + } + return ExecCommandResolution( + rawExecutable: resolution.rawExecutable, + resolvedPath: resolution.resolvedPath, + scriptCandidatePath: scriptCandidatePath, + executableName: resolution.executableName, + cwd: resolution.cwd) + } + 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/ExecWrapperResolution.swift b/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift index d099d6a1771..89280bc2786 100644 --- a/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift @@ -79,6 +79,15 @@ enum ExecWrapperResolution { private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"]) private static let transparentDispatchWrappers = Set(["nice", "nohup", "stdbuf", "timeout"]) + private static let shellWrapperOptionsWithValue = Set([ + "-c", + "--command", + "-o", + "+o", + "--rcfile", + "--init-file", + "--startup-file", + ]) 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"]) @@ -285,6 +294,43 @@ enum ExecWrapperResolution { return current } + static func resolveShellWrapperScriptCandidatePath(_ argv: [String], cwd: String?) -> String? { + let effective = self.unwrapShellInspectionArgv(argv) + guard let token0 = self.trimmedNonEmpty(effective.first) else { + return nil + } + + let normalized = self.normalizeExecutableToken(token0) + guard let spec = self.findShellWrapperSpec(normalized) else { + return nil + } + guard self.extractShellWrapperPayload(effective, spec: spec) == nil else { + return nil + } + guard let scriptIndex = self.findShellWrapperScriptTokenIndex(effective) else { + return nil + } + + let scriptToken = effective[scriptIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !scriptToken.isEmpty else { + return nil + } + + let expanded = scriptToken.hasPrefix("~") + ? (scriptToken as NSString).expandingTildeInPath + : scriptToken + if expanded.hasPrefix("/") { + return expanded + } + + let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let root = (trimmedCwd?.isEmpty == false) ? trimmedCwd! : FileManager().currentDirectoryPath + return URL(fileURLWithPath: root) + .appendingPathComponent(expanded) + .standardizedFileURL + .path + } + static func hasEnvManipulationBeforeShellWrapper(_ argv: [String]) -> Bool { self.hasEnvManipulationBeforeShellWrapperInternal( argv, @@ -456,6 +502,43 @@ enum ExecWrapperResolution { self.shellWrapperSpecs.first { $0.names.contains(baseExecutable) } } + private static func findShellWrapperScriptTokenIndex(_ argv: [String]) -> Int? { + guard argv.count >= 2 else { + return nil + } + + 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 == "--" { + idx += 1 + break + } + if lower == "-c" || lower == "--command" || self.isCombinedShellModeFlag(lower, flag: "c") { + return nil + } + if lower == "-s" || self.isCombinedShellModeFlag(lower, flag: "s") { + return nil + } + if self.shellWrapperOptionsWithValue.contains(lower) { + idx += 2 + continue + } + if token.hasPrefix("-") || token.hasPrefix("+") { + idx += 1 + continue + } + break + } + + return idx < argv.count ? idx : nil + } + private static func extractShellWrapperPayload(_ argv: [String], spec: ShellWrapperSpec) -> String? { switch spec.kind { case .posix: @@ -629,6 +712,17 @@ enum ExecWrapperResolution { return commandIndex + 1 } + private static func isCombinedShellModeFlag(_ lowerToken: String, flag: Character) -> Bool { + let chars = Array(lowerToken) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return false + } + if chars.dropFirst().contains("-") { + return false + } + return chars.dropFirst().contains(flag) + } + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { guard let idx = argv.firstIndex(where: { let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index fec0c111269..e5fb916b70d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -59,6 +59,18 @@ struct ExecAllowlistTests { cwd: nil) } + private static func shellScriptFixture() throws -> (dir: URL, bash: URL, script: URL) { + let dir = try makeTempDirForTests() + let bash = dir.appendingPathComponent("bash") + try makeExecutableForTests(at: bash) + + let scriptsDir = dir.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true) + let script = scriptsDir.appendingPathComponent("save_crystal.sh") + FileManager.default.createFile(atPath: script.path, contents: Data("echo ok\n".utf8)) + return (dir, bash, script) + } + @Test func `match uses resolved path`() { let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") let resolution = Self.homebrewRGResolution() @@ -211,6 +223,20 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "sh") } + @Test func `resolve for allowlist attaches shell script candidate path`() throws { + let fixture = try Self.shellScriptFixture() + + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: ["bash", "scripts/save_crystal.sh"], + rawCommand: nil, + cwd: fixture.dir.path, + env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]) + + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == fixture.bash.path) + #expect(resolutions[0].scriptCandidatePath == fixture.script.path) + } + @Test func `resolve for allowlist unwraps env shell wrapper chains`() { let command = [ "/usr/bin/env", @@ -367,6 +393,22 @@ struct ExecAllowlistTests { #expect(evaluation.allowlistResolutions[0].executableName == "printf") } + @Test func `allowlist matcher falls back to shell script candidate path`() throws { + let fixture = try Self.shellScriptFixture() + + let resolution = ExecCommandResolution.resolveForAllowlist( + command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"], + rawCommand: nil, + cwd: fixture.dir.path, + env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]).first + + let match = ExecAllowlistMatcher.match( + entries: [ExecAllowlistEntry(pattern: fixture.script.path)], + resolution: resolution) + + #expect(match?.pattern == fixture.script.path) + } + @Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() { let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"], @@ -390,6 +432,30 @@ struct ExecAllowlistTests { #expect(!patterns.contains("/usr/bin/nice")) } + @Test func `allow always patterns persist shell script paths without inline commands`() throws { + let fixture = try Self.shellScriptFixture() + + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["bash", "scripts/save_crystal.sh"], + cwd: fixture.dir.path, + env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]) + + #expect(patterns == [fixture.script.path]) + #expect(!patterns.contains(fixture.bash.path)) + } + + @Test func `allow always patterns persist shell script paths through dispatch wrappers`() throws { + let fixture = try Self.shellScriptFixture() + + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"], + cwd: fixture.dir.path, + env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]) + + #expect(patterns == [fixture.script.path]) + #expect(!patterns.contains(fixture.bash.path)) + } + @Test func `allow always patterns unwrap busybox shell applets to inner executables`() throws { let tmp = try makeTempDirForTests() let busybox = tmp.appendingPathComponent("busybox") @@ -428,17 +494,17 @@ struct ExecAllowlistTests { #expect(patterns.isEmpty) } - @Test func `allow always patterns support max transparent wrapper depth`() throws { + @Test func `allow always patterns stop at shared transparent wrapper depth limit`() throws { let tmp = try makeTempDirForTests() let whoami = tmp.appendingPathComponent("whoami") try makeExecutableForTests(at: whoami) let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( - command: ["nice", "nohup", "timeout", "5", "stdbuf", "-o", "L", "whoami"], + command: ["nice", "nohup", "timeout", "5", "whoami"], cwd: tmp.path, env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) - #expect(patterns == [whoami.path]) + #expect(patterns.isEmpty) } @Test func `match all requires every segment to match`() {