diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index f6daf9f7523..38b8f02d401 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -44,13 +44,12 @@ struct ExecCommandResolution { return [] } var resolutions: [ExecCommandResolution] = [] - resolutions.reserveCapacity(segments.count) for segment in segments { - guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env) - else { + let segmentResolutions = self.resolveShellSegmentExecutions(segment, cwd: cwd, env: env) + guard !segmentResolutions.isEmpty else { return [] } - resolutions.append(resolution) + resolutions.append(contentsOf: segmentResolutions) } return resolutions } @@ -113,18 +112,14 @@ struct ExecCommandResolution { cwd: cwd) } - private static func resolveShellSegmentExecutable( + private static func resolveShellSegmentExecutions( _ segment: String, cwd: String?, - env: [String: String]?) -> ExecCommandResolution? + env: [String: String]?) -> [ExecCommandResolution] { let tokens = self.tokenizeShellWords(segment) - guard !tokens.isEmpty else { return nil } - let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(tokens) - guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + guard !tokens.isEmpty else { return [] } + return self.resolveForAllowlist(command: tokens, rawCommand: nil, cwd: cwd, env: env) } private static func collectAllowAlwaysPatterns( @@ -139,6 +134,11 @@ struct ExecCommandResolution { return } + // Allow-always persistence intentionally peels known dispatch wrappers + // directly so approvals stay scoped to the launched executable instead of + // the wrapper binary. The allowlist path stays stricter for semantic + // wrapper usage (for example `env FOO=bar ...`) and may still require + // re-approval in those cases. switch ExecWrapperResolution.unwrapKnownDispatchWrapperInvocation(command) { case .blocked: return diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift index 3db3589d0b9..8b7391c8078 100644 --- a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -88,8 +88,4 @@ enum ExecEnvInvocationUnwrapper { guard !expectsOptionValue, idx < command.count else { return nil } return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers) } - - static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { - ExecWrapperResolution.unwrapDispatchWrappersForResolution(command) - } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 23ad35da76c..605cbdb8b4f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -264,6 +264,22 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "whoami") } + @Test func `resolve for allowlist recurses through nested shell wrappers after dispatch wrappers`() throws { + let tmp = try makeTempDirForTests() + let whoami = tmp.appendingPathComponent("whoami") + try makeExecutableForTests(at: whoami) + + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: ["/bin/sh", "-lc", "nice /bin/zsh -lc whoami"], + rawCommand: nil, + cwd: tmp.path, + env: ["PATH": "\(tmp.path):/usr/bin:/bin"]) + + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == whoami.path) + #expect(resolutions[0].executableName == "whoami") + } + @Test func `resolve for allowlist unwraps direct dispatch wrappers with canonical raw command`() { let command = ["/usr/bin/nice", "/usr/bin/printf", "ok"] let resolutions = ExecCommandResolution.resolveForAllowlist(